Domain Layer
The Domain layer is where your core business logic lives.
It is designed to stay independent from Laravel, Eloquent, and HTTP concerns, so your business rules remain easy to test, reason about, and evolve over time.
This layer models the essential concepts of your SaaS — pricing rules, permissions, billing invariants — without being tied to any specific framework or infrastructure.
backend/app/Domain/
Directory Structure
The Domain layer is organized by business domain. Each domain contains the same set of building blocks:
Domain/
├── Auth/
│ ├── Contracts/ # OAuthProvider interface
│ └── DTO/ # OAuthUser transfer DTO
├── Billing/
│ ├── Contracts/ # PaymentGatewayInterface, InvoicingStrategyInterface, ...
│ ├── DTO/ # CheckoutSession, TaxCalculation, InvoiceDraft, ...
│ ├── Enums/ # PricingType, SubscriptionStatus, BillingMode, ...
│ ├── Exceptions/ # AlreadySubscribedException, BillingNotReadyException, ...
│ ├── Services/ # BillingModeResolver, CurrencyResolver, ...
│ └── ValueObjects/ # BillingInterval, InvoicePdfSource
├── Catalog/
│ ├── Enums/ # EntitlementType
│ ├── Exceptions/ # SystemFeatureImmutableException
│ └── Rules/ # CanAccessFeature
├── Shared/
│ └── ValueObjects/ # Money
├── Team/
│ ├── Contracts/ # InvitationNotifierInterface
│ ├── Enums/ # InvitationStatus, TeamRole
│ ├── Exceptions/ # TeamException
│ ├── Support/ # ResolvesTeamRole, TeamContext
│ └── ValueObjects/ # InvitationToken
└── Usage/
├── Contracts/ # ReconciliationServiceInterface, UsageReporterInterface
├── Enums/ # AggregationType, QuotaEnforcement, ResetInterval
├── Exceptions/ # QuotaExceededException, DuplicateUsageEventException
├── Services/ # QuotaChecker, UsageCalculator, ...
└── ValueObjects/ # UsagePeriod
Why Keep Domain Rules Framework-Agnostic
Domain rules, value objects, enums, and contracts never depend on Laravel facades, Eloquent, or HTTP concerns. This keeps business logic fast to test, easy to reason about, and isolated from infrastructure choices.
In practice, orchestration that needs Eloquent models belongs in the Application layer, which may pass normalized data into the Domain when needed.
Domain code remains largely framework-agnostic. This gives you three benefits:
- Testability — You can test business rules without bootstrapping the framework. A pure enum or value object test runs in milliseconds.
- Portability — Domain logic is not locked to Laravel. If you ever swap frameworks (unlikely, but possible), this layer moves with you unchanged.
- Separation — Business rules live in one place. You never wonder whether a pricing calculation is buried in a controller, a middleware, or a model.
BillingReadinessChecker::isReady(Tenant $tenant)). This is a pragmatic trade-off to avoid unnecessary mapping layers. However, orchestration and data retrieval remain the responsibility of the Application layer.Contracts (Interfaces)
Contracts define ports — the boundaries between your application and external systems. The Domain declares what it needs; the Infrastructure layer provides the implementations.
Here is the payment gateway contract. Every billing Action depends on this interface, never on Stripe directly:
interface PaymentGatewayInterface
{
/**
* Create or retrieve a customer in the payment provider.
*
* @return string The provider's customer ID
*/
public function ensureCustomer(Tenant $tenant): string;
/**
* Create a checkout session for subscription signup.
*/
public function createCheckoutSession(CreateCheckoutData $data): CheckoutSession;
/**
* Create a subscription in the provider.
*
* @return array<string, mixed> Provider subscription data
*/
public function createSubscription(
string $customerId,
string $priceId,
array $options = []
): array;
/**
* Cancel a subscription.
*
* @param bool $immediately If false, cancel at period end
*/
public function cancelSubscription(
string $subscriptionId,
bool $immediately = false
): void;
/**
* Resume a subscription that was scheduled for cancellation.
*/
public function resumeSubscription(string $subscriptionId): void;
/**
* Update subscription (change plan, quantity).
*
* @return array<string, mixed> Updated subscription data
*/
public function updateSubscription(
string $subscriptionId,
array $params
): array;
/**
* Get the billing portal URL for self-service management.
*/
public function getBillingPortalUrl(string $customerId, string $returnUrl): string;
}
Not every contract is this large. Some are simple single-method interfaces:
interface InvitationNotifierInterface
{
/**
* Send an invitation notification to the invitee.
*/
public function sendInvitation(Invitation $invitation, string $plainToken): void;
}
The pattern is the same: the Domain declares what it needs, Infrastructure provides how.
DTOs (Transfer Objects)
Domain DTOs represent normalized business data. They flow from Infrastructure mappers back through Actions. They are provider-agnostic — no stripe_* fields, no SDK-specific types.
final readonly class CheckoutSession
{
public function __construct(
public string $sessionId,
public string $url,
) {}
}
Whether the checkout session came from Stripe, Paddle, or a test fake, it looks the same to the Application layer.
Enums
Enums are not just value labels — they carry business behavior. Methods on enums encode rules that the rest of the codebase can rely on:
enum PricingType: string
{
case Flat = 'flat';
case Seat = 'seat';
case Usage = 'usage';
public function supportsQuantity(): bool
{
return $this === self::Seat;
}
public function requiresUsageData(): bool
{
return $this === self::Usage;
}
}
The supportsQuantity() method is used by billing calculations to determine whether a plan's price depends on team size. Instead of scattering if ($plan->pricing_type === 'seat') checks throughout the codebase, the knowledge lives in one place.
Here is another example with richer behavior:
enum SubscriptionStatus: string
{
case Active = 'active';
case Trialing = 'trialing';
case PastDue = 'past_due';
case Canceled = 'canceled';
case Unpaid = 'unpaid';
case Paused = 'paused';
case Incomplete = 'incomplete';
case IncompleteExpired = 'incomplete_expired';
public function isActive(): bool
{
return in_array($this, [self::Active, self::Trialing], true);
}
public function canUpgrade(): bool
{
return in_array($this, [self::Active, self::Trialing, self::PastDue], true);
}
public function canCancel(): bool
{
return in_array($this, [self::Active, self::Trialing, self::PastDue], true);
}
}
When an Action needs to know whether a subscription can be upgraded, it calls $subscription->status->canUpgrade() — no manual status comparisons.
Value Objects
Value objects are immutable objects that represent a concept with value semantics. Two Money objects with the same amount and currency are equal, regardless of where they were created.
The Money value object is the most used one in the codebase. It enforces currency consistency across all arithmetic operations:
final readonly class Money implements \JsonSerializable
{
private function __construct(
public int $amount,
public string $currency,
) {}
// Factory methods
public static function cents(int $amount, string $currency): self
{
return new self($amount, strtoupper($currency));
}
public static function zero(string $currency): self
{
return new self(0, strtoupper($currency));
}
public static function fromMajor(float $amount, string $currency): self
{
$minorUnits = CurrencyRegistry::minorUnits($currency);
$cents = (int) round($amount * (10 ** $minorUnits));
return new self($cents, strtoupper($currency));
}
// Arithmetic with currency guards
public function add(Money $other): self
{
$this->assertSameCurrency($other);
return new self($this->amount + $other->amount, $this->currency);
}
public function subtract(Money $other): self
{
$this->assertSameCurrency($other);
return new self($this->amount - $other->amount, $this->currency);
}
public function multiply(int|float $factor): self
{
return new self((int) round($this->amount * $factor), $this->currency);
}
// Comparison and formatting methods omitted for brevity
// Serialization (CONTRACTS.md §1.4)
public function jsonSerialize(): array
{
return [
'amount' => $this->amount,
'currency' => $this->currency,
];
}
private function assertSameCurrency(Money $other): void
{
if ($this->currency !== $other->currency) {
throw CurrencyMismatchException::forOperation(
$this->currency,
$other->currency
);
}
}
}
Attempting to add EUR to USD throws a CurrencyMismatchException. This prevents a class of bugs that would otherwise surface only in production.
A simpler value object — BillingInterval validates its invariant at construction time:
final readonly class BillingInterval
{
public function __construct(
public IntervalUnit $unit,
public int $count,
) {
if ($count < 1) {
throw new InvalidArgumentException('Interval count must be at least 1');
}
}
public function toArray(): array
{
return [
'unit' => $this->unit->value,
'count' => $this->count,
];
}
}
Rules
A Rule encapsulates a reusable business condition. Rules depend only on ValueObjects, Enums, or primitives — never on Eloquent, repositories, or providers.
final readonly class CanAccessFeature
{
/**
* Check if access to a feature is allowed.
*
* @param EntitlementType $type The type of entitlement (boolean or quota)
* @param int|null $value The quota limit (null = unlimited)
* @param int|null $currentUsage Current usage count
*/
public function __invoke(
EntitlementType $type,
?int $value,
?int $currentUsage
): bool {
return match ($type) {
EntitlementType::Boolean => true,
EntitlementType::Quota => $this->checkQuota($value, $currentUsage),
};
}
private function checkQuota(?int $limit, ?int $usage): bool
{
if ($limit === null) {
return true; // Unlimited
}
return ($usage ?? 0) < $limit;
}
}
This rule is pure logic. It does not query the database or call any service — it just answers the question "given this entitlement type and these numbers, is access allowed?"
Rule vs Policy
| Characteristic | Rule | Policy |
|---|---|---|
| Location | Domain/<Domain>/Rules/ | app/Policies/ |
| Dependencies | ValueObjects, Enums, primitives | User, Model (Eloquent) |
| Purpose | Pure business condition | Authorization check |
| Framework | None | Laravel Gate/Policy |
| Example | CanAccessFeature | SubscriptionPolicy |
When to choose: if the check needs database data ("is this user authorized to cancel?"), use a Policy. If it is pure logic on values ("does this entitlement type allow access given current usage?"), use a Rule.
Services
Domain Services coordinate multiple Domain concepts. They resolve state, compute results, or apply business logic that does not belong to a single entity or value object.
final class BillingModeResolver
{
/**
* Get the current billing mode from configuration.
*/
public function current(): BillingMode
{
$configuredMode = config('billing.mode');
if ($configuredMode === null) {
return BillingMode::default();
}
$mode = BillingMode::tryFrom($configuredMode);
return $mode ?? BillingMode::default();
}
/**
* Get the current billing mode, throwing if unsupported.
*
* @throws UnsupportedBillingModeException
*/
public function currentOrFail(): BillingMode
{
$mode = $this->current();
if (! $mode->isSupported()) {
throw UnsupportedBillingModeException::forMode($mode);
}
return $mode;
}
public function isPlatformManaged(): bool
{
return $this->current()->isPlatformManaged();
}
}
The BillingModeResolver is used by the Infrastructure layer's strategy pattern to determine which invoicing strategy to use.
Exceptions
Domain exceptions represent business constraint violations. They extend DomainException and use static factory methods for clarity:
final class AlreadySubscribedException extends \DomainException
{
public function __construct(
public readonly string $tenantId,
public readonly string $planId,
string $message = '',
) {
parent::__construct($message ?: 'Tenant is already subscribed to this plan');
}
public static function forPlan(string $tenantId, string $planId): self
{
return new self(
$tenantId,
$planId,
"Tenant {$tenantId} is already subscribed to plan {$planId}"
);
}
}
The static factory pattern (AlreadySubscribedException::forPlan(...)) makes exception creation self-documenting. The readonly properties allow controllers to extract structured data for API error responses.
The boilerplate ships with domain exceptions for every business constraint: BillingNotReadyException, CurrencyMismatchException, QuotaExceededException, PlanNotEligibleException, and more. Each carries the context needed to produce a meaningful API error response.
Summary: What Goes in Domain
| Building Block | Purpose | Count in Codebase |
|---|---|---|
| Contracts | Interfaces for external services | ~10 |
| DTOs | Provider-agnostic transfer data | ~25 |
| Enums | Typed states with business behavior | ~12 |
| ValueObjects | Immutable concepts (Money, Interval, Token) | ~5 |
| Rules | Pure business conditions | 1+ |
| Services | Multi-concept coordination | ~19 |
| Exceptions | Business constraint violations | ~20 |
What's Next
- Application Layer — How use cases orchestrate Domain logic with Actions and Queries
- Infrastructure Layer — How Domain Contracts get real implementations