Dansday

The Month a Country Became a Config Value

Published on Jun 30, 2026

One hundred and sixty-six contributions in June, split roughly evenly:

3cat-Sdn-Bhd/3cat                18 pull requests   (private)
dansday-com/dansday-discord-bot   5 pull requests   102 commits
dansday-com/dansday-main          0

The Discord bot came back from one commit in May to a hundred and two. This website stayed at zero for a second month. And the largest thing I opened at work was an attempt to make the shop exist in a second country, which sat for seven weeks and was then closed without merging.

A country became a config value

On 22 June I opened PH: Initial Release — 777 additions, 52 deletions, thirty-two files, plus a 91-line release runbook. The Philippines.

The design is the best-documented thing I have written in this codebase, and the comment at the top of the new config/country.php explains it better than I could paraphrase:

| Every country (including MY) is derived from the country code applied to a
| single unsuffixed base value:
|   - database / username : <DB_DATABASE>_<cc>      e.g. 3cat -> 3cat_my, 3cat_ph
|   - connection name      : mysql_<cc>
|   - s3 buckets           : <AWS_BUCKET>-<cc>       e.g. 3cat-dev-assets-my / -ph
|   - host                 : "-<cc>" before the first dot of APP_URL
|
| Adding a country = add its code to $countries below. The only per-country
| secret is the database password (DB_PASSWORD_<CC>).
$countries = ['MY', 'PH'];

Everything is convention. The database name, the username, the connection name, the bucket names and the hostname all derive from one country code applied to one base value, so adding a third country is a two-letter string in an array plus one secret. Any of it can still be overridden per country with a matching environment variable when the convention does not hold — which it does not for production, because the Malaysian site is 3cat.my and the Philippine one is 3cat.ph, a different top-level domain rather than a suffix.

The switching happens once per request in a new service provider:

config([
    'database.default'              => $country['connection'],
    'database.redis.options.prefix' => $country['redis_prefix'],
    'session.cookie'                => config('session.cookie').$country['suffix'],
    'cache.prefix'                  => config('cache.prefix').$country['suffix'],
    'app.url'                       => $country['app.url'],
    'app.warranty_url'              => $country['warranty_url'],

    'filesystems.disks.public.bucket'         => $country['aws_bucket'],
    'filesystems.disks.s3.bucket'             => $country['aws_bucket'],
    'filesystems.disks.remote-configs.bucket' => $country['s3_configs_bucket'],
]);

Look at lines three and four. The session cookie and the cache prefix both get the country suffix appended.

Two months earlier I spent 272 lines of diagnostics working out why admin sessions kept going stale, and the answer was that the storefront and the admin panel were sharing one session cookie while sitting behind different caching. The fix was a middleware giving the admin panel its own cookie. In June I wrote the same defence at a larger scale before it could bite — two countries sharing a session cookie or a cache prefix would mean a Malaysian session resolving against Philippine data, which is a considerably worse version of the same bug.

That is the first time in this series I can point at a lesson from one month being applied preemptively in another rather than relearned.

Separate databases, and a provider that bails in the console

The significant architectural choice is that each country gets its own database rather than a shared one with a country column.

| Base (unsuffixed) DB identity and the default country. Each country uses
| <base>_<cc> (e.g. 3cat -> 3cat_my, 3cat_ph). The bare "mysql" connection --
| the CLI / migration / queue default -- points at the default country so
| `php artisan migrate` targets MY without flags; other countries are reached
| with --database=mysql_<cc>.

Full isolation: no country column to forget in a where clause, no risk of one country's report including another's orders. The cost is that every migration now runs twice, every queue worker needs to know which country it is draining, and any cross-country question requires querying two databases and joining in application code.

The provider handles the console case explicitly:

public function register(): void
{
    if ($this->app->runningInConsole()) {
        return;
    }

Bail out entirely on the command line, so the bare connection stays pointed at the default country and php artisan migrate keeps working without arguments. That is a small decision with a large blast radius if it were wrong — a service provider that rewrote database.default during a console run would send migrations at whichever country the environment happened to imply.

Currency deliberately is not in that config. The comment says so: it comes from each channel's base currency in its own database, managed in the Bagisto admin. Country is infrastructure; currency is content.

The TEMP block with a deletion condition

At the end of the provider is the part I want to hold up, not because it is elegant but because of how it is labelled:

// TEMP(3cat.ph→staging): PH uses fake stores + customer stories on staging.
// Swap the stores config and merge the PH customer-stories override.
// Remove this block (and config/3cat/stores_ph.php, config/home_ph.php) when PH prod is live.
if (Option::getCc() === 'PH') {
    config([
        '3cat.stores' => require config_path('3cat/stores_ph.php'),
        'home'        => array_merge(config('home'), require config_path('home_ph.php')),
        'global'      => array_merge(config('global'), require config_path('global_ph.php')),
    ]);
}

The Philippine site has no real shops yet, so it runs on invented stores and invented customer stories. That is a temporary lie in production config, and it is marked as one: what it is, why it is there, and the exact condition under which it should be deleted — including the names of the two files that go with it.

In April 2024 I wrote about finding {{-- TODO - check if they want the whole card clickable --}} in a template and finally answering it after weeks. This is what a TODO looks like when it states its own expiry.

The pull request itself never merged. It was closed on 11 August, seven weeks after I opened it, on the same day a larger Philippine infrastructure pull request went in. So this was the design, and the thing that shipped was its successor. I do not know from the repository whether the approach survived into that one, and I will find out when I write about August.

first() was wrong

The other substantial ticket is about vouchers, and it found a bug I shipped in December.

The current promo engine limits each configuration to a single voucher code.
This makes it difficult to manage campaigns like referral programs or bulk
student discounts where the discount logic is identical but the codes must
be unique.

One promotion, many codes. Every student gets their own single-use code, all pointing at the same discount rule.

In December 2025 I made vouchers stackable by extending Bagisto's cart rule helper and overriding the method that decides whether a rule applies. That was the correction to a mistake from October 2024, and I was pleased with it. Here is what June found in it:

- $coupon = $rule->cart_rule_coupon()->whereIn('code', $cartCouponCodes)->first();
+ $coupons = $rule->cart_rule_coupon()->whereIn('code', $cartCouponCodes)->get();

The comment I wrote alongside the fix explains the failure exactly:

// A rule may own many codes; fetch every code in the cart that belongs to
// this rule and accept the rule if AT LEAST ONE of them is still redeemable.
// Using first() would let a single exhausted code (e.g. a used single-use
// student code) disqualify the whole rule for the other still-valid codes.

In December a rule could only own one code, so first() was correct by accident. The moment a rule could own many, it became a bug that silently rejects a valid discount because some other code on the same rule had already been used. That is the fourth or fifth time in this series that a lookup returning one row where it should return several has cost me something.

I also owe December an amendment. I criticised myself then for stacking vouchers by overloading the existing single coupon_code field with a comma-separated list, and called it the cheapest possible implementation. Reading the framework code properly this month, Bagisto stores applied_cart_rule_ids as a comma-separated string itself. The convention was already there; I followed it rather than inventing it. That does not make it a good data model, but it makes it a consistent one, and my December paragraph was unfair to a decision that had a precedent I had not checked.

Single-use codes need bookkeeping

Multiple codes per rule needed something the framework does not do, so there is a 97-line listener extending Bagisto's own:

* Overrides the core single-coupon behaviour to support multiple
* comma-separated coupon codes mapped to one or more cart rules. Each
* matched coupon's usage counters are incremented individually so that
* single-use codes (uses_per_coupon = 1) are deactivated after redemption
* while multi-use codes (uses_per_coupon = 0) stay active.

When an order is placed it walks every applied rule, increments the rule's own usage counter, and then increments each individual coupon's counter separately. Without that, a student's single-use code would stay redeemable forever, or every code on the rule would burn together.

Note the shape: extend the framework's listener, override one method. Same as December's helper, same as the category controller I extended in January 2024 and the attribute repository in November 2024. Twenty-nine months in, this is the only technique in this codebase I would call a habit rather than a decision.

May's bulk feature lost 249 lines

In May I shipped 1,468 additions and no deletions for bulk catalogue updates — an export, a migration, and a 733-line service that applies an edited spreadsheet back onto the products.

In June it lost 249 lines. The bespoke exporter, all 161 lines of it, was deleted and replaced by the product data grid's existing export. And the matching logic lost its second key:

- * Bulk-load products for the given Autocount values and SKUs in two
- * queries, eager-loading inventories.
+ * Bulk-load products for the given Autocount SKU ID values, eager-loading
+ * inventories. Matching is by Autocount only — there is no SKU fallback.

Rows had been matched by an accounting-system identifier or, failing that, by SKU. Now it is the accounting identifier alone. Two ways to identify the same product means two ways for an upload to bind a row to the wrong one, and quietly — a fallback that fires when the primary key misses is a fallback that fires exactly when something is already wrong.

A feature built in one month and simplified by a sixth in the next. That is the same shape as November 2024, when I closed my own 863-line pull request the day after a 137-line version of it merged, except this time I got to ship the big one first and pay for it afterwards.

The bot grew an economy

I have been treating the Discord bot as a footnote in these articles and June is where that stops being defensible. It took a hundred and two commits against the day job's eighteen pull requests, and its largest single change — 7,625 additions across fifty-seven files — is bigger than the Philippines work I just spent four sections on.

What it added is a currency. Levelling has existed in this bot since November 2025: members earn experience points for participating. In June those points became money, and there is now somewhere to spend them.

The schema is three tables and it is the clearest statement of the design:

CREATE TABLE items (
    panel_id INT NOT NULL,
    name VARCHAR(150) NOT NULL,
    effect_type VARCHAR(32) NOT NULL,
    cost INT NOT NULL DEFAULT 0,
    config JSON NOT NULL DEFAULT ('{}'),
    enabled BOOLEAN NOT NULL DEFAULT TRUE,
    available_from DATETIME NULL,
    available_to DATETIME NULL,
    recurring_schedule JSON NULL,
    sort_order INT NOT NULL DEFAULT 0,
    ...
);

An item is a type of effect with a price and a JSON blob of parameters. It has an availability window and a recurring schedule, so a shop can have seasonal stock and weekly rotations without anybody deploying code. That is the same instinct as everything I have built for the marketing team at work — put the thing that changes weekly into a table, not a template.

CREATE TABLE server_member_items (
    member_id INT NOT NULL,
    item_id INT NOT NULL,
    quantity INT NOT NULL DEFAULT 1,
    UNIQUE KEY unique_server_member_item (member_id, item_id),
    ...
);

Inventory as one row per member per item with a quantity, rather than one row per copy. That unique key is the interesting decision: it makes "how many of these do I have" a single lookup and makes double-granting impossible by construction, which is a database-level answer to the class of bug I patched by hand in November 2025 when experience points were being awarded twice on join.

CREATE TABLE server_member_item_actives (
    member_item_id INT NOT NULL,
    effect_value DECIMAL(6,2) NOT NULL DEFAULT 0,
    beneficiary_member_id INT NULL,
    target_member_id INT NULL,
    expires_at DATETIME NOT NULL,
    expiry_notified BOOLEAN NOT NULL DEFAULT FALSE,
    INDEX idx_..._active (member_item_id, expires_at),
    INDEX idx_..._beneficiary (beneficiary_member_id, expires_at),
    ...
);

The third table is where the game actually lives, and the two nullable columns are the whole design. An active effect has a beneficiary and a target, and they are not the same person. That is what makes items usable on other members rather than only on yourself, and it is what makes the leech mechanic possible — one member siphoning experience from another, with the siphoner as beneficiary and the victim as target.

Both indexes are composite and both end in expires_at, which tells you the query that matters: what is currently in effect, either on this item or for this beneficiary, right now. Somebody thought about the read pattern before writing the table.

Five verbs on a currency

The API surface added in that pull request is five endpoints, and reading them as a list tells you what kind of thing this is:

POST /api/items/[serverSlug]/buy       +57
POST /api/items/[serverSlug]/use       +58
POST /api/items/[serverSlug]/gamble    +53
POST /api/items/[serverSlug]/discard   +51
POST /api/admin/items/gift             +73

Buy, use, discard, gift — and gamble. Experience points earned by talking in a Discord server can be wagered through an HTTP endpoint.

I am going to name that plainly rather than let it sit inside a feature list. This bot's origin is a Roblox community, and Roblox's audience skews young. A gambling mechanic denominated in a currency you earn by participating is a loot-box-shaped thing, and I built it as one endpoint among five without writing down a single line of reasoning about it. There is no age gate in that handler and no note in the pull request. I am not going to pretend I weighed it and concluded it was fine; I did not weigh it at all, and the honest version of this article says so.

What the handler does check is worth reading, because it is a pattern I keep reaching for:

const itemsSettings = (itemsRow as any)?.settings || {};
if (itemsSettings.enabled !== true) {
    return json({ success: false, error: 'The items shop is disabled for this server.' }, { status: 403 });
}

Every one of those endpoints refuses to act unless the server has explicitly switched the items component on, and the default of a missing setting is off. Combined with the per-item enabled column in the schema, and the usable flag added a week later in the leech hotfix, there are now three independent ways to turn a piece of this economy off: the whole component per server, one item's availability, and one item's usability.

That is the February 2024 checkout feature flag again — the query parameter that let the team walk a live payment flow while ordinary visitors saw no button. When you are shipping something that touches value, ship the switch first. It is the only instinct in these thirty-one months I have applied consistently in both codebases.

A token in the URL

The authentication in those endpoints is not a session:

const actor = await resolveMemberByCardToken(server.id, String(card));
if (!actor) return json({ success: false, error: 'Member not found' }, { status: 404 });

Members identify themselves with a card token, which is the member card feature I shipped in April and did not describe properly at the time. The public pages follow the same shape — /server/[serverSlug]/items/bag/[category]/[hash] — with the token in the path.

That is a capability URL, and it is a reasonable choice: a Discord member clicking a link from a chat message has no account on the web panel, and asking them to create one to spend their points would kill the feature. The trade is the one every capability URL makes. Anyone holding the link is that member for as long as the token is valid, and links in a chat channel are not private. I have not put an expiry on it, and I should.

Refusing to start

The change I would show another engineer is eighty-five lines called botSingletonLock.ts: a Redis lock with a thirty-second expiry, renewed every ten seconds, with a fencing window derived from the gap between the two.

What matters is what it does when Redis is missing:

if (isRedisConfigured()) {
    logger.error('Bot singleton lock unavailable: Redis configured but unreachable; refusing to start', { kind, botId });
    return null;
}

If Redis is configured and unreachable, the bot does not start. It does not fall back to running without a lock.

That is the opposite of the instinct I have applied nearly everywhere else in these articles. The promotion system at work fails soft — catch, log, return null, show a card without a badge, because a broken decoration should not take down a page. A singleton lock cannot fail soft. Two processes holding the same Discord token both receive every event, so both award experience points for the same message and both charge for the same purchase — which, now that experience points are currency, is not a cosmetic bug.

That is exactly the failure I patched in November 2025 in a pull request titled XP duplication fix on join. I fixed the symptom then. The lock is the structural version, and the unique key on the inventory table is the third layer. Same bug, three defences, seven months apart, and I only see them as one thing writing this paragraph.

An item you can switch off

Ten days after the shop shipped, a hotfix for the leech item closed two exploits:

Your leech on ${who} is still active — you can only run one at a time.
This member is already being leeched by ${who}.

One member could run several leeches simultaneously, and several members could leech the same victim at once. Both are the same missing constraint — nothing checked whether an effect of that type was already active for that beneficiary or against that target — and the server_member_item_actives table has the indexes to answer both questions, which is why the fix is a lookup rather than a schema change.

Both are also only findable by players trying it. A shop with a PvP item in it is an adversarial system by design: the users are actively looking for the combination you did not think of, which is a kind of testing you cannot buy and cannot schedule.

The fix I would keep from that hotfix is one line of migration adding a usable flag, so an item can be disabled without being deleted and says so: This item has been disabled and can no longer be used. Inventory already bought stays in people's bags; the effect stops working. Deleting the item would have been simpler and would have taken things people paid for.

The rest of the bot's June: an items page with minigames and guides at 4,209 additions across forty-nine files, a friend boost system, and 1,975 lines added to a single stylesheet — which is its own kind of confession about how much interface arrived at once.

What June was

Twenty-three pull requests. A design for running the shop in two countries, from separate databases with suffixed sessions and caches, which did not merge. Multiple voucher codes per promotion, which exposed that December's stacking implementation would reject valid discounts as soon as it was used as intended. May's bulk update tooling reduced by a sixth. A bot that refuses to start rather than run twice, and a game economy with a kill switch for individual items.

The thing I would point at is $countries = ['MY', 'PH']; and the paragraph of comment above it. Twenty-nine months ago I joined a repository that could not display a product, and the first thing I did was tear pages into components so that assembling a new one was cheap. This is that instinct at a different altitude: not writing the Philippines, but writing the thing that makes a country cheap. It did not merge, and I still think it is the best code I wrote this month.

The thing I would change is the gamble endpoint. Not because it does not work — because I shipped a wagering mechanic to a young audience as the third item in a list of five, with no reasoning recorded anywhere. The Philippines design has a paragraph of comment explaining how to add a country. That endpoint has none explaining why it exists.