Skip to content
SaaS4Builders
Backend

Application Layer

Use case orchestration with Actions (mutations), Queries (reads), and Input DTOs — the bridge between HTTP and Domain.

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

QuestionActionQuery
Mutates state?YesNo
Uses DB::transaction()?YesNo
Returns?Usually a modelPaginator, collection, or model
Side effects allowed?Yes (events, external calls)Never
Naming conventionCreateX, CancelX, UpdateXListX, 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:

ConventionCorrectWrong
Action class nameCreateSubscriptionCreateSubscriptionAction
Query class nameListInvoicesListInvoicesQuery
Input DTOCreateSubscriptionDataCreateSubscriptionDTO
Filter DTOListTenantsFiltersListTenantsFilterDTO

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.

backend/app/Application/Billing/DTO/CreateSubscriptionData.php
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 Request references, no header access

The Request-to-DTO Flow

The toDto() method is the bridge between HTTP and Application:

backend/app/Http/Requests/Api/V1/Tenant/CreateCheckoutRequest.php
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 TypeSuffixLocationExample
Input DTO*DataApplication/<Domain>/DTO/CreateSubscriptionData
Filter DTO*FiltersApplication/<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:

backend/app/Application/Billing/Actions/CreateSubscription.php
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:

  1. Constructor injection of interfaces — depends on PaymentGatewayInterface, not StripePaymentGateway
  2. Business validation before mutation — checks readiness, configuration, and uniqueness before proceeding
  3. Domain exceptionsBillingNotReadyException, AlreadySubscribedException carry structured context for API error responses
  4. 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 like viaCheckout()
  • 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.

backend/app/Application/Billing/Queries/ListInvoices.php
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:

Application/<Domain>/Queries/ListTenants.php
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);
    }
}
Always whitelist filters, sorts, and includes explicitly with 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.

Never wrap Queries in a transaction. Queries are read-only by definition. If you find yourself adding 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:

backend/app/Http/Controllers/Api/V1/Tenant/SubscriptionController.php
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:

backend/app/Http/Controllers/Api/V1/Tenant/SubscriptionController.php
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:

  1. Receive the request (validated by the FormRequest)
  2. Call $request->toDto() to get the Input DTO (for Actions)
  3. Call the Action or Query
  4. Return a Resource

No business logic. No query building. No formatting. The controller delegates.


What's Next