Application Layer
The Application layer contains your use cases. Actions handle mutations (create, update, delete, cancel). Queries handle reads (list, find, search). Input DTOs carry validated data from the HTTP layer without any HTTP dependency.
backend/app/Application/
Directory Structure
The Application layer is organized by business domain, mirroring the Domain layer. Each domain has up to three directories:
Application/
├── Admin/
│ ├── Actions/ # CreatePlatformUser, UpdateGlobalRole, ...
│ ├── DTO/ # CreateGlobalRoleData, ListTenantsFilters, ...
│ └── Queries/ # ListTenants, ListPlatformUsers, ...
├── Auth/
│ ├── Actions/ # LoginUser, RegisterUser, RefreshAccessToken, ...
│ └── DTO/ # LoginUserData, RegisterUserData, ...
├── Billing/
│ ├── Actions/ # CreateSubscription, CancelSubscription, ChangePlan, ...
│ ├── DTO/ # CreateSubscriptionData, CancelSubscriptionData, ...
│ └── Queries/ # ListInvoices, GetSubscription, ListPlans, ...
├── Catalog/
│ ├── Actions/ # CreateProduct, CreatePlan, CreateFeature, SyncPlanPrices, ...
│ ├── DTO/ # CreateProductData, CreatePlanData, PriceData, ...
│ └── Queries/ # ListProducts, ListPlans, FindPlanById, ...
├── Entitlements/
│ └── Queries/ # GetTenantEntitlements
├── Impersonation/
│ ├── Actions/ # StartImpersonation, StopImpersonation
│ ├── DTO/ # StartImpersonationData, StopImpersonationData
│ └── Queries/ # GetImpersonationStatus
├── Notifications/
│ ├── Actions/ # MarkNotificationAsRead, MarkAllNotificationsAsRead
│ ├── DTO/ # ListNotificationsFilters
│ └── Queries/ # ListNotifications
├── Onboarding/
│ ├── Actions/ # StartOnboarding, CompleteOnboarding, RetryCheckout
│ ├── DTO/ # StartOnboardingData, CompleteOnboardingData, ...
│ └── Queries/ # GetOnboardingStatus
├── Push/
│ ├── Actions/ # StorePushSubscription, DeletePushSubscription
│ └── DTO/ # StorePushSubscriptionData
├── Team/
│ ├── Actions/ # InviteTeamMember, AcceptInvitation, ChangeMemberRole, ...
│ ├── DTO/ # CreateInvitationData, TeamMemberData, ...
│ └── Queries/ # ListTeamMembers, GetTeamStats, ListInvitations, ...
├── Tenants/
│ ├── Actions/ # CreateTenant, UpdateTenant, SwitchTenant
│ └── DTO/ # CreateTenantData, UpdateTenantData
├── Usage/
│ ├── Actions/ # RecordUsage
│ ├── DTO/ # RecordUsageData, UsageSummaryData, ...
│ └── Queries/ # GetCurrentUsage, GetMeterUsage, GetQuotaStatus, ...
└── Users/
├── Actions/ # UpdateUserProfile, UploadUserAvatar, DeleteUserAvatar
└── DTO/ # UpdateUserProfileData
Actions vs Queries
| Question | Action | Query |
|---|---|---|
| Mutates state? | Yes | No |
Uses DB::transaction()? | Yes | No |
| Returns? | Usually a model | Paginator, collection, or model |
| Side effects allowed? | Yes (events, external calls) | Never |
| Naming convention | CreateX, CancelX, UpdateX | ListX, FindX, GetX |
The rule: if it changes anything — database, external service, file system — it is an Action.
Naming Conventions
Classes in the Application layer follow a strict no-suffix convention:
| Convention | Correct | Wrong |
|---|---|---|
| Action class name | CreateSubscription | CreateSubscriptionAction |
| Query class name | ListInvoices | ListInvoicesQuery |
| Input DTO | CreateSubscriptionData | CreateSubscriptionDTO |
| Filter DTO | ListTenantsFilters | ListTenantsFilterDTO |
Add a suffix only in case of naming collision (rare).
Input DTOs
Input DTOs carry validated data from the HTTP layer to the Application layer. They are created by FormRequest::toDto() and have zero HTTP dependency.
final readonly class CreateSubscriptionData
{
public function __construct(
public Tenant $tenant,
public Plan $plan,
public string $currency,
public string $successUrl,
public string $cancelUrl,
public int $quantity = 1,
public ?int $trialDays = null,
) {}
}
Notice:
final readonly class— immutable, no subclassing- Properties use PHP native types — no validation logic here (that belongs in the FormRequest)
- The DTO knows nothing about HTTP — no
Requestreferences, no header access
The Request-to-DTO Flow
The toDto() method is the bridge between HTTP and Application:
public function toDto(Tenant $tenant): CreateSubscriptionData
{
return new CreateSubscriptionData(
tenant: $tenant,
plan: Plan::findOrFail($this->validated('plan_id')),
currency: $this->validated('currency'),
successUrl: $this->validated('success_url'),
cancelUrl: $this->validated('cancel_url'),
quantity: $this->integer('quantity', 1),
);
}
By the time the Action receives the DTO, the data is validated, typed, and framework-free.
DTO Naming
| DTO Type | Suffix | Location | Example |
|---|---|---|---|
| Input DTO | *Data | Application/<Domain>/DTO/ | CreateSubscriptionData |
| Filter DTO | *Filters | Application/<Domain>/DTO/ | ListTenantsFilters |
| Transfer DTO | (varies) | Domain/<Domain>/DTO/ | CheckoutSession |
Input DTOs carry data to use cases. Transfer DTOs carry data from external systems (see Domain Layer).
Actions in Detail
An Action is one business transaction. It validates business rules, orchestrates mutations, and delegates external operations to Domain Contracts.
Here is the complete CreateSubscription action:
final class CreateSubscription
{
public function __construct(
private PaymentGatewayInterface $gateway,
private BillingReadinessChecker $checker,
) {}
public function viaCheckout(CreateSubscriptionData $data): CheckoutSession
{
// 1. Check billing readiness (tenant fields)
if (! $this->checker->isReady($data->tenant)) {
throw BillingNotReadyException::forTenant(
$data->tenant->id,
$this->checker->getMissingFields($data->tenant)
);
}
// 2. Check plan configuration (seat pricing requires team_members feature)
if (! $this->checker->isPlanConfiguredForCheckout($data->plan)) {
throw SeatPricingMisconfiguredException::forPlan(
$data->plan->id,
$this->checker->getSeatFeatureCode()
);
}
// 3. Check not already subscribed to this plan
$existingSubscription = Subscription::where('tenant_id', $data->tenant->id)
->where('plan_id', $data->plan->id)
->whereIn('status', [
SubscriptionStatus::Active,
SubscriptionStatus::Trialing,
SubscriptionStatus::PastDue,
])
->exists();
if ($existingSubscription) {
throw AlreadySubscribedException::forPlan(
$data->tenant->id,
$data->plan->id
);
}
// 4. Get PlanPrice for currency
$planPrice = $data->plan->getPriceForCurrency($data->currency);
if (! $planPrice) {
throw new PlanNotAvailableInCurrencyException(
$data->plan->id,
$data->currency,
$data->plan->getAvailableCurrencies()
);
}
// 5. Force seat quantity to actual member count for seat-based plans
$quantity = $data->quantity;
if ($data->plan->pricing_type === PricingType::Seat) {
$quantity = max(1, $data->tenant->users()->count());
}
// 6. Build checkout data and create session via gateway
$checkoutData = new CreateCheckoutData(
tenant: $data->tenant,
planPrice: $planPrice,
successUrl: $data->successUrl,
cancelUrl: $data->cancelUrl,
quantity: $quantity,
trialDays: $data->trialDays
?? ($data->plan->trial_days > 0 ? $data->plan->trial_days : null),
);
return $this->gateway->createCheckoutSession($checkoutData);
}
}
Key patterns in this Action:
- Constructor injection of interfaces — depends on
PaymentGatewayInterface, notStripePaymentGateway - Business validation before mutation — checks readiness, configuration, and uniqueness before proceeding
- Domain exceptions —
BillingNotReadyException,AlreadySubscribedExceptioncarry structured context for API error responses - Gateway delegation — the external call is a single line; all Stripe complexity is hidden behind the interface
Action Conventions
- All classes are
final - Single public method:
execute()or a named method likeviaCheckout() - Wrap state changes in
DB::transaction() - Return an Eloquent Model or a Domain DTO
- Throw domain exceptions for constraint violations
- Depend on interfaces (Domain Contracts), never on providers directly
Queries in Detail
Queries are read-only. No side effects. They return paginated results, collections, or single models.
final class ListInvoices
{
/**
* List invoices for a tenant with optional filtering.
*
* @return LengthAwarePaginator<int, Invoice>
*/
public function execute(string $tenantId, ListInvoicesFilters $filters): LengthAwarePaginator
{
return Invoice::query()
->where('tenant_id', $tenantId)
->when($filters->status, fn ($query, $status) => $query->where('status', $status))
->with(['lines', 'taxRecords'])
->orderBy('issue_date', 'desc')
->paginate($filters->perPage, ['*'], 'page', $filters->page);
}
}
This query is 10 lines. It does one thing: filter invoices by tenant and status, eager load relationships, and paginate. No side effects, no transactions, no external calls.
Queries with Spatie QueryBuilder
For endpoints that expose dynamic client-side filtering and sorting, use Spatie\QueryBuilder:
use Spatie\QueryBuilder\QueryBuilder;
use Spatie\QueryBuilder\AllowedFilter;
final readonly class ListTenants
{
public function __invoke(ListTenantsData $data): LengthAwarePaginator
{
return QueryBuilder::for(Tenant::class)
->allowedFilters([
AllowedFilter::exact('status'),
AllowedFilter::partial('name'),
AllowedFilter::scope('created_after'),
])
->allowedSorts(['name', 'created_at'])
->allowedIncludes(['subscription'])
->defaultSort('-created_at')
->paginate($data->perPage);
}
}
allowedFilters(), allowedSorts(), and allowedIncludes(). Never let clients query arbitrary fields.Query Conventions
- No
DB::transaction()— queries are read-only by definition - No side effects (no events, no external calls, no state changes)
- May use Eloquent, Query Builder, or Spatie QueryBuilder
- Eager load relationships to prevent N+1 queries
Transaction Handling
Actions own the transaction boundary. All mutations happen inside DB::transaction():
use Illuminate\Support\Facades\DB;
public function execute(CreateTenantData $data): Tenant
{
return DB::transaction(function () use ($data) {
$tenant = Tenant::create([
'name' => $data->name,
'billing_email' => $data->billingEmail,
]);
$tenant->users()->attach($data->userId, ['role' => TeamRole::Owner->value]);
return $tenant;
});
}
If any step inside the closure fails, the entire transaction is rolled back. This prevents partial writes.
DB::transaction() to a Query, it is actually an Action.How Controllers Call Actions and Queries
Controllers are thin. They receive a request, call an Action or Query, and return a Resource. Here are two methods from SubscriptionController:
Query call — reading the current subscription:
public function show(string $tenantId, GetSubscription $query): JsonResponse
{
$subscription = $query->execute($tenantId);
if ($subscription === null) {
return response()->json(['data' => null]);
}
return response()->json([
'data' => new SubscriptionResource($subscription),
]);
}
Action call — canceling a subscription:
public function cancel(
string $tenantId,
CancelSubscriptionRequest $request,
CancelSubscription $action,
GetSubscription $getSubscription
): SubscriptionResource {
$subscription = $getSubscription->execute($tenantId);
if ($subscription === null) {
abort(404, 'No active subscription found');
}
$result = $action->execute($request->toDto($subscription));
return new SubscriptionResource($result->load('plan'));
}
Both follow the same pattern:
- Receive the request (validated by the FormRequest)
- Call
$request->toDto()to get the Input DTO (for Actions) - Call the Action or Query
- Return a Resource
No business logic. No query building. No formatting. The controller delegates.
What's Next
- Domain Layer — The pure business logic that Actions orchestrate
- Infrastructure Layer — How Domain Contracts get real implementations
Domain Layer
Framework-agnostic business logic: contracts, DTOs, enums, value objects, domain rules, services, and exceptions - everything that defines how your SaaS actually works.
Infrastructure Layer
External integrations, provider implementations, mappers, strategy patterns, and service provider bindings.