DEV Community

Cover image for Bounded Contexts in PHP: Why Your App Should Be 4 Apps in One Codebase
Gabriel Anhaia
Gabriel Anhaia

Posted on

Bounded Contexts in PHP: Why Your App Should Be 4 Apps in One Codebase


Open app/Models/ in a four-year-old Laravel monolith. User.php is 800 lines. It has a subscriptions() relation, an orders() relation, a productsViewed() relation, a loginAttempts() relation, a billingAddress accessor, an isAdmin() method, a markAsFraudulent() method, and twelve scopes. Six different teams have touched it in the last quarter. Every PR to it merges with at least one merge conflict.

You scroll to the top of the file. The class extends Authenticatable. So now the password reset flow, the fraud rules, the recommendation engine, the order history, and the invoice address all live inside one class that the framework treats as the identity object.

That is what happens when one application pretends to be one domain. It isn't. It's four domains in a trench coat.

This is the part of DDD that survives without the whole DDD apparatus. You don't need a domain expert, a ubiquitous-language workshop, or six color sticky notes on a wall to use bounded contexts. You need to recognize that the word user means a different thing in four different rooms of your application, and you need to stop forcing those four meanings through one PHP class.

What a bounded context actually is, in PHP terms

A bounded context is a region of your code where a word has one specific meaning, with one specific shape, owned by one specific team or feature area. Cross the boundary and the same word means something else.

User in Identity is an account: email, password hash, MFA factors, session tokens, last-seen-at.

User in Catalog is a viewer: their browsing history, their wishlists, the regions and currencies they see prices in.

User in Order is a buyer: their shipping addresses, their cart, their order history, their preferred payment method.

User in Billing is a payer: their invoice profile, their VAT number, their dunning state, their open balance, their tax jurisdiction.

These are four objects. They share an ID. They share nothing else. Password-hash format changes have nothing to do with dunning state. Tax jurisdiction shifts have nothing to do with the wishlist. If those four concerns live in one User class, every change touches everyone.

Four bounded contexts share an ID, not a class

The fix is mechanical. Stop modelling them as one class. Model them as four.

The namespace plan

A team I talked to last year ran a mid-size e-commerce platform on Laravel. Every domain concern was in App\Models\ and App\Http\Controllers\. They were trying to extract Billing into its own service. Multiple attempts failed because Billing's User queries hit users.subscription_tier, which Identity owned but Marketing wrote to, and the users table had become a wide, multi-purpose dumping ground.

The refactor that finally landed didn't touch the database first. It started with namespaces:

src/
├── Identity/
│   ├── Domain/
│   │   ├── Account.php
│   │   ├── AccountId.php
│   │   └── EmailAddress.php
│   ├── Application/
│   │   ├── RegisterAccount.php
│   │   └── AuthenticateAccount.php
│   └── Infrastructure/
│       ├── DoctrineAccountRepository.php
│       └── BcryptPasswordHasher.php
├── Catalog/
│   ├── Domain/
│   │   ├── Product.php
│   │   ├── Sku.php
│   │   └── Viewer.php
│   ├── Application/
│   │   └── BrowseCatalog.php
│   └── Infrastructure/
│       └── EloquentProductRepository.php
├── Order/
│   ├── Domain/
│   │   ├── Order.php
│   │   ├── Buyer.php
│   │   ├── Cart.php
│   │   └── Money.php
│   ├── Application/
│   │   ├── PlaceOrder.php
│   │   └── CancelOrder.php
│   └── Infrastructure/
│       └── PostgresOrderRepository.php
└── Billing/
    ├── Domain/
    │   ├── Invoice.php
    │   ├── Payer.php
    │   └── TaxJurisdiction.php
    ├── Application/
    │   └── IssueInvoice.php
    └── Infrastructure/
        └── StripeBillingGateway.php
Enter fullscreen mode Exit fullscreen mode

Four contexts. Each one has its own Domain, Application, and Infrastructure directories. Each one owns its own version of the entity formerly known as User. Each one has its own repository, its own use cases, its own infrastructure adapters.

composer.json enforces it through PSR-4 roots, and deptrac (or a simple phpstan boundary rule) blocks Order\ from importing Billing\Domain\Invoice directly. The contexts talk to each other through events or thin shared kernels, not through cross-namespace imports.

What each User looks like

In Identity, Account is the account record. Password hash, email, MFA state, nothing else:

<?php

declare(strict_types=1);

namespace App\Identity\Domain;

final class Account
{
    public function __construct(
        public readonly AccountId $id,
        public readonly EmailAddress $email,
        private string $passwordHash,
        private ?MfaFactor $mfa = null,
    ) {}

    public function verifyPassword(
        string $plaintext,
        PasswordHasher $hasher,
    ): bool {
        return $hasher->verify($plaintext, $this->passwordHash);
    }

    public function rotatePassword(
        string $newPlaintext,
        PasswordHasher $hasher,
    ): void {
        $this->passwordHash = $hasher->hash($newPlaintext);
    }
}
Enter fullscreen mode Exit fullscreen mode

In Order, Buyer is whoever is placing the order. It does not know about passwords. It knows about shipping addresses and order history:

<?php

declare(strict_types=1);

namespace App\Order\Domain;

final class Buyer
{
    public function __construct(
        public readonly BuyerId $id,
        public readonly ShippingAddress $defaultAddress,
        /** @var list<OrderId> */
        public readonly array $pastOrderIds,
    ) {}

    public function isReturning(): bool
    {
        return count($this->pastOrderIds) > 0;
    }
}
Enter fullscreen mode Exit fullscreen mode

In Billing, Payer is whoever owes money. It does not know about shipping addresses or passwords. It knows about VAT, dunning state, and outstanding balance:

<?php

declare(strict_types=1);

namespace App\Billing\Domain;

final class Payer
{
    public function __construct(
        public readonly PayerId $id,
        public readonly ?VatNumber $vatNumber,
        public readonly TaxJurisdiction $jurisdiction,
        public readonly DunningState $dunning,
        public readonly Money $outstandingBalance,
    ) {}

    public function isOnHold(): bool
    {
        return $this->dunning === DunningState::Suspended
            || $this->outstandingBalance->isGreaterThan(
                Money::fromCents(50_000, 'EUR'),
            );
    }
}
Enter fullscreen mode Exit fullscreen mode

AccountId, BuyerId, PayerId are all wrappers around the same UUID. The underlying value is identical. The types are not. A function that takes a PayerId will not accept an AccountId, even though they hold the same string. That is the type system doing the boundary work for you.

"But it's the same person"

It is. The person is the same. The model of the person, inside each context, is not.

Think of it this way. Your bank sees you as an account holder. Your gym sees you as a member. Your dentist sees you as a patient. The same human, three completely different records, three completely different schemas. Nobody at the bank is trying to merge your fluoride history into your savings account. Nobody at the gym keeps your tax ID on file.

Software teams forget this because the shared ID (user_id) and shared table (users) are right there, obvious, in front of everyone. The temptation is to put everything that uses that ID into one place. Resist it. The shared ID is a foreign key, not an excuse to share a schema.

In code, the boundary looks like this:

<?php

namespace App\Order\Application;

use App\Identity\Domain\AccountId;
use App\Order\Domain\BuyerId;
use App\Order\Domain\Buyer;
use App\Order\Domain\BuyerRepository;
use App\Order\Domain\Cart;
use App\Order\Domain\OrderId;

final class PlaceOrder
{
    public function __construct(
        private readonly BuyerRepository $buyers,
    ) {}

    public function handle(AccountId $account, Cart $cart): OrderId
    {
        $buyerId = BuyerId::fromAccountId($account);
        $buyer = $this->buyers->findOrCreate($buyerId);

        return $buyer->place($cart);
    }
}
Enter fullscreen mode Exit fullscreen mode

The use case accepts an AccountId because the caller is the HTTP layer and the HTTP layer was authenticated by Identity. Inside Order, it converts that to a BuyerId and works with Buyer. Order never imports App\Identity\Domain\Account. It accepts the ID at the door and that is the whole relationship.

The use case translates IDs at the boundary, not models

How the contexts actually talk

Two patterns cover 90% of inter-context communication:

Shared IDs and translation at the boundary. The HTTP controller in Order receives the authenticated AccountId from Identity's middleware, and the Order use case translates it to its own BuyerId. No object crosses the boundary. Only the ID does.

Domain events. When something interesting happens in one context, it emits an event. Other contexts subscribe if they care.

<?php

namespace App\Order\Domain\Events;

final class OrderPlaced
{
    public function __construct(
        public readonly OrderId $orderId,
        public readonly BuyerId $buyer,
        public readonly Money $total,
        public readonly \DateTimeImmutable $placedAt,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

Order publishes OrderPlaced. Billing has a subscriber that picks it up, looks up or creates a Payer by ID, and issues an invoice. Identity has a subscriber that updates the account's last_purchase_at for fraud scoring. Catalog has a subscriber that decrements stock.

<?php

namespace App\Billing\Application;

use App\Billing\Domain\PayerId;
use App\Order\Domain\Events\OrderPlaced;

final class IssueInvoiceOnOrderPlaced
{
    public function __construct(
        private readonly IssueInvoice $issueInvoice,
    ) {}

    public function __invoke(OrderPlaced $event): void
    {
        $this->issueInvoice->handle(
            PayerId::fromBuyerId($event->buyer),
            $event->orderId,
            $event->total,
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Order does not know Billing exists. Billing knows that an OrderPlaced event exists, and what fields it carries, and that is the entire contract between the two contexts.

Separate tables, separate Doctrine mappings

The namespace split earns its keep when the database follows. You don't need four databases. You need four logical schemas inside one.

  • identity.accounts — email, password_hash, mfa_factors, created_at
  • catalog.viewers — viewer_id, region, currency, recently_viewed_skus
  • sales.buyers — buyer_id, default_shipping_address_id
  • sales.orders — order_id, buyer_id, total_cents, status, placed_at
  • billing.payers — payer_id, vat_number, jurisdiction, dunning_state
  • billing.invoices — invoice_id, payer_id, amount_cents, issued_at, paid_at

(order is a reserved word in most SQL dialects, so the Order context's schema is named sales here. Pick a non-reserved name your database is happy with.)

Postgres schemas, MySQL databases on the same server, or just a naming prefix in SQLite — pick what your operations team can run. The shape is the same. Identity owns accounts. Nobody else writes to it. Order owns buyers and orders. Nobody else writes to them. Billing owns the rest.

The migration files live in their owning context too:

src/Order/Infrastructure/Migrations/
src/Billing/Infrastructure/Migrations/
Enter fullscreen mode Exit fullscreen mode

When you run the test suite for the Order context, you run only Order's migrations against a clean test database. When you deploy Billing, only Billing's migrations run. Cross-context queries are illegal at the application layer — if Billing needs an account's email to send a dunning notice, Identity exposes a read endpoint, or Billing keeps the email it cares about on its own Payer record, updated by event.

When a "User" CRUD form needs all four

It will. The admin panel needs to show one screen for "user 1234" with their account info, their order history, their open invoice, and their viewing region. That is fine. That admin screen is itself a fifth context — call it Backoffice or Support — and it composes data from the other four via their read APIs.

<?php

namespace App\Backoffice\Application;

final class GetCustomerSnapshot
{
    public function __construct(
        private readonly IdentityReadModel $identity,
        private readonly OrderReadModel $order,
        private readonly BillingReadModel $billing,
        private readonly CatalogReadModel $catalog,
    ) {}

    public function handle(AccountId $id): CustomerSnapshot
    {
        return new CustomerSnapshot(
            account: $this->identity->find($id),
            recentOrders: $this->order->recentFor(BuyerId::fromAccountId($id)),
            openInvoices: $this->billing->openFor(PayerId::fromAccountId($id)),
            viewingRegion: $this->catalog->viewerFor(ViewerId::fromAccountId($id)),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Five lookups. Each one against the context that owns the data. No JOIN across schemas. No User::with('subscriptions', 'orders', 'invoices', 'viewedProducts') cathedral query. Each context is queried for what it owns, and the Backoffice context composes the answer.

That composition is cheap. It is one HTTP round-trip per context if the contexts are ever split into services, and one in-process function call per context if they stay in the same monolith. Either way, the read path is honest about what is happening: four different domains contribute four different pieces of one report.

What this gives you

Two teams can ship Identity and Billing changes in the same week without touching each other's PRs.

A change to MFA does not require regression-testing the dunning rules.

When users.subscription_tier (a Marketing concern) needs a new value, it lives in a Marketing context's own table — not on the Identity record that auth depends on.

When Billing eventually does split out into its own service, the seam is already cut. The events are already named. The IDs are already typed. The migration is a deploy boundary, not a six-month refactor.

You can run the four contexts as one PHP process forever and still benefit. Bounded contexts are not a microservices on-ramp. They are a way to keep one codebase from collapsing into one undifferentiated User model. Whether you ever physically split them is a separate decision.

What you don't get away with anymore is pretending your e-commerce app is one domain. It's four. Once the namespaces admit it, the rest gets easier.


If this was useful

The full breakdown — bounded contexts, the dependency rule, how Use Cases compose them, and the migration playbook for getting a legacy Laravel or Symfony monolith into this shape — is what Decoupled PHP is about. It walks the same Identity / Order / Billing / Catalog example from a CRUD-soup starting point all the way to a clean four-context layout, in production, without a freeze week.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)