Pragmatic Laravel Layered Architecture
A practical backend architecture for Laravel projects that keeps code split by clear responsibilities, helping the codebase stay maintainable as it grows and easier to test.
This approach does not try to force pure Clean Architecture, strict DDD, or other abstractions into Laravel. It simply extends the standard way of building Laravel applications by adding a few practical architectural components and rules that make layer boundaries and responsibilities clearer.
Architecture Overview
This section describes the main components of our application architecture:
- Http / Console / MCP
- Actions
- Core
- Contracts
- Integrations
- Models
- Values
- Data
- Enums
- Events / Listeners
- Jobs
- Casts
- Providers
- Exceptions
1. Http / Console / MCP
Question: How does the outside world enter the system?
Responsibility: Entry points of the application: HTTP controllers, API, form requests, console commands, MCP tools. They receive input, validate or normalize request data, call an Action, and return output.
Rules:
- Keep this layer thin.
- Do not put business logic here.
- Use separate Request classes to validate incoming data.
- Do not orchestrate complex workflows here.
Path: app/Http/Controllers/, app/Http/Requests/, app/Console/Commands/, app/Mcp/
Example:
class PlaceOrderController
{
public function store(PlaceOrderRequest $request, PlaceOrderAction $action): JsonResponse
{
$order = $action->handle(PlaceOrderData::fromRequest($request));
return response()->json(['id' => $order->id]);
}
}
2. Actions
Question: What should the system do in this use case?
Responsibility: Application/use-case layer. Actions coordinate one clear business scenario, define the order of steps, call Core components, use Models when needed, and dispatch Events or Jobs when needed. An Action does not have to be a dumb proxy. It can contain use-case-specific logic. But when some piece of logic becomes important, reusable, or complex, it should move to Core.
Rules:
- One Action = one use case.
- Name Actions by intent:
CreateOrderInvoiceActionetc. - Keep orchestration here, not reusable domain mechanics
Path: app/Actions/{Domain}/
Example:
class PlaceOrderAction
{
public function __construct(
private PaymentGateway $payments,
private CartTotalCalculator $totals,
) {}
public function handle(PlaceOrderData $data): Order
{
...
$total = $this->totals->calculate($data->items);
$order = Order::create([...]);
OrderPlaced::dispatch($order);
return $order;
}
}
3. Core
Question: How does the product’s core logic work?
Core contains domain services for business logic that does not naturally belong to a single Model.
Folder naming: Core folders should be named by product area or internal mechanism, not by generic technical type.
Good: Core/AlertRules Core/MarketData Core/PriceDigests
Avoid: Core/Services Core/Helpers Core/Processors
Rules:
- Put domain logic that does not belong to a single Model in Core.
- Keep logic related to a Model’s own state and behavior in the Model.
- Keep use-case orchestration and side effects in Actions.
- Core should not depend on HTTP, console, MCP, or response formats.
- Use Contracts when Core needs external services or integrations.
Path: app/Core/{ProductArea}/
Example:
// app/Core/Shipping/FreeShippingRule.php
final readonly class FreeShippingRule
{
public function __construct(
private Money $minimumOrderTotal,
) {}
public function appliesTo(Customer $customer, Order $order): bool
{
return $customer->isPremium()
|| $order->total()->isAtLeast($this->minimumOrderTotal);
}
}
4. Contracts
Question: What does the system need to be able to do, without knowing who does it?
Responsibility: Interfaces for capabilities that may have different implementations. Contracts describe what the application needs, not how it is implemented. Use Contracts when the implementation is external, technical, or replaceable.
Path: app/Contracts/
Example:
interface PaymentGateway
{
public function charge(Money $amount, string $token): ChargeResult;
}
5. Integrations
Question: How do we talk to external systems or technical infrastructure?
Responsibility: Concrete implementations of Contracts and adapters to external APIs, SDKs, services, or infrastructure.
Rules:
- Keep vendor-specific code here
- Implement Contracts, do not define business rules
- Core should depend on Contracts, not concrete Integrations
Path: app/Integrations/{Provider}/
Example:
final class StripePaymentGateway implements PaymentGateway
{
public function charge(Money $amount, string $token): ChargeResult
{
// Stripe-specific code here
}
}
6. Models
Question: What persisted entities exist, how are they stored, and how do they behave?
Responsibility: Eloquent models represent persisted entities. They define database mapping, relationships, casts, scopes, accessors/mutators, and business logic related to their own state and behavior.
Rules:
- Keep logic related to a Model’s own state and behavior in the Model.
- Keep relationships, casts, scopes, and state transitions in Models.
- Use Core when logic does not naturally belong to a single Model.
- Keep a balance between anemic and rich domain models.
Path: app/Models/
Example:
class Order extends Model
{
protected $casts = [
'status' => OrderStatus::class,
'total' => MoneyCast::class,
];
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
public function cancel(): void
{
if (!$this->status->canBeCancelled()) {
throw new OrderCannotBeCancelled($this->status);
}
$this->status = OrderStatus::Cancelled;
$this->cancelled_at = now();
}
}
7. Values
Question: Is this primitive just a technical value, or does it represent a meaningful domain concept?
Responsibility: Value Objects for meaningful domain values that may contain validation, invariants, normalization, or behavior.
Rules:
- use Values to avoid primitive obsession and keep domain meaning explicit
Path: app/Values/
Example:
final class Money
{
public function __construct(
public readonly int $cents,
public readonly string $currency,
) {}
public function add(Money $other): self
{
return new self($this->cents + $other->cents, $this->currency);
}
}
8. Data
Question: What data shape is passed between parts of the system?
Responsibility: DTOs/data carriers. Data objects make method signatures explicit and avoid raw arrays when the shape is important.
Rules:
- Value Object = meaningful domain value with behavior/invariants
- Data Object = structured data container
Path: app/Data/
Example:
final class PlaceOrderData
{
public function __construct(
public int $customerId,
public array $items,
public string $paymentToken,
) {}
}
9. Enums
Question: What fixed set of states or types exists in the system?
Responsibility: Fixed allowed values and states. Enums replace magic strings and centralize allowed values.
Path: app/Enums/
Example:
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
}
10. Events / Listeners
Question: What happened, and what should react to it?
Responsibility: Use Events when a meaningful system fact happens and the next reactions should be decoupled from the main use case. They make side effects, notifications, broadcasting, and async workflows easier to separate and trace.
Rules:
- Keep Events small
- Events should describe what happened
- Do not put business logic in Events
- Keep Listeners thin
- Do not hide large business workflows inside Listeners
Path: app/Events/, app/Listeners/
Example:
// app/Events/OrderPlaced.php
class OrderPlaced
{
public function __construct(public Order $order) {}
}
// app/Listeners/SendOrderConfirmation.php
class SendOrderConfirmation
{
public function __construct(private Mailer $mailer) {}
public function handle(OrderPlaced $event): void
{
$this->mailer->send($event->order);
}
}
11. Jobs
Question: What work needs controlled background execution?
Jobs define how work is executed outside the main flow. They are used for slow, retryable, delayed, chunked, or external-service-related work. A Job should usually call an Action or Core component instead of owning the main business logic itself.
Rules:
- Jobs describe execution, not business responsibility
- Use Jobs for background work, retries, delays, chunks, and batches
- Do not use Jobs as primary business services
- Keep business flow in Actions
- Keep reusable product logic in Core
Path: app/Jobs/
Example:
class GenerateInvoicePdfJob implements ShouldQueue
{
public function __construct(public int $orderId) {}
public function handle(GenerateInvoicePdfAction $action): void
{
$action->handle($this->orderId);
}
}
12. Casts
Question: How is a database value converted into an object and back?
Responsibility: Bridge between Eloquent persistence and domain-friendly objects. Use Casts to convert DB/JSON values to Value Objects and back.
Path: app/Casts/
Example:
class MoneyCast implements CastsAttributes
{
public function get($model, $key, $value, $attributes): Money
{
$data = json_decode($value, true);
return new Money($data['cents'], $data['currency']);
}
public function set($model, $key, $value, $attributes): string
{
return json_encode(['cents' => $value->cents, 'currency' => $value->currency]);
}
}
13. Providers
Question: How is the application wired together?
Responsibility: Laravel framework wiring. Providers bind interfaces to implementations, configure services, and register framework-specific behavior.
Path: app/Providers/
Example:
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(PaymentGateway::class, StripeGateway::class);
}
}
14. Exceptions
Use custom Exceptions when a failure has product meaning or needs to be handled differently.
Path: app/Exceptions/
Example:
final class OrderAlreadyPaidException extends DomainException
{
public static function for(Order $order): self
{
return new self("Order {$order->id} is already paid.");
}
}