Laravel teams often adopt repositories, services, actions, and DTOs with good intentions. They want cleaner controllers, testable business logic, and code that will survive future changes. The problem starts when every store() method becomes a stack of abstractions before the project has a real boundary to protect.
The useful question is not “Should Laravel apps use clean architecture?” A better question is: which boundary is expensive enough to deserve a layer? In Laravel, Eloquent models, form requests, policies, jobs, events, casts, validation, and service container wiring already give you architectural tools. Adding more structure can help, but only when it reduces real coupling instead of just moving code into more files.
The core rule: a layer must pay rent
A repository, service, action, or DTO is justified when it changes one of these operational properties:
Reduces the number of places that must change when business rules change
Makes a use case testable without HTTP, database, or external API coupling
Protects the domain from transport-specific input such as request arrays
Isolates an unstable dependency such as payment, shipping, search, or CRM integration
Makes transaction boundaries explicit
Makes failure handling and retry behavior easier to reason about
Allows the same use case to be called from HTTP, CLI, queue workers, or scheduled tasks
If none of those properties change, the new layer is probably ceremony.
A layer is not architecture because it has a name. It becomes architecture when it protects a meaningful boundary.
Simple CRUD usually does not need a repository
A common mistake is wrapping every Eloquent call in a repository because “controllers should not know about models.” In Laravel, that rule is often imported from environments where the ORM is thinner, less expressive, or less integrated with the framework.
For ordinary CRUD, Eloquent is already the repository-like abstraction over database rows. A controller using a FormRequest, policy authorization, and an Eloquent model is not automatically “dirty.” It can be direct, readable, and easy to maintain.
final class ProductController
{
public function store(StoreProductRequest $request): RedirectResponse
{
$product = Product::create($request->validated());
return redirect()
->route('products.show', $product)
->with('status', 'Product created.');
}
public function update(UpdateProductRequest $request, Product $product): RedirectResponse
{
$product->update($request->validated());
return redirect()
->route('products.show', $product)
->with('status', 'Product updated.');
}
}This is not a “fat controller.” The controller coordinates HTTP input, validation has moved into a request object, persistence is expressed through the model, and there is no hidden business process.
A repository here often adds indirection without removing coupling:
final class ProductRepository
{
public function create(array $data): Product
{
return Product::create($data);
}
public function update(Product $product, array $data): Product
{
$product->update($data);
return $product;
}
}This does not isolate the database. It still returns an Eloquent model. It does not improve testability in a meaningful way. It also gives the team another class to name, mock, review, and maintain.
For CRUD, prefer the framework path until there is a real reason to move logic out.
When an Action becomes useful
An action is useful when the operation has a business name and can be triggered from more than one entry point. A checkout is not a controller method. It is a use case.
Checkout may involve:
Validating current cart state
Reserving stock
Creating an order
Charging a payment method
Recording audit information
Dispatching jobs or events
Handling partial failure
Keeping a transaction boundary visible
That is too much responsibility for a controller. It also should not be scattered across model observers, random service methods, and controller branches.
final readonly class CheckoutData
{
public function __construct(
public int $userId,
public int $cartId,
public string $paymentMethodId,
public ?string $couponCode = null,
) {}
}The DTO is useful here because it gives the use case a stable input contract. The action does not need to know about Request, route parameters, session state, or raw arrays. That matters when checkout can later be triggered by an API endpoint, an admin tool, or a recovery job.
final class CheckoutAction
{
public function __construct(
private InventoryService $inventory,
private PaymentGateway $payments,
) {}
public function execute(CheckoutData $data): Order
{
return DB::transaction(function () use ($data) {
$cart = Cart::query()
->whereKey($data->cartId)
->where('user_id', $data->userId)
->lockForUpdate()
->firstOrFail();
$this->inventory->reserve($cart);
$order = Order::fromCart($cart, [
'coupon_code' => $data->couponCode,
]);
$this->payments->charge(
order: $order,
paymentMethodId: $data->paymentMethodId,
);
$cart->markCheckedOut();
return $order;
});
}
}The controller becomes thin for the right reason:
final class CheckoutController
{
public function __invoke(
CheckoutRequest $request,
CheckoutAction $checkout
): RedirectResponse {
$order = $checkout->execute(new CheckoutData(
userId: $request->user()->id,
cartId: $request->integer('cart_id'),
paymentMethodId: $request->string('payment_method_id')->toString(),
couponCode: $request->input('coupon_code'),
));
return redirect()->route('orders.show', $order);
}
}Now the action has a clear job. The DTO has a clear job. The controller has a clear job. The boundary is not decorative.
Services are for capabilities, not random verbs
UserService, ProductService, and OrderService often become dumping grounds. They start as “business logic” containers and end as files where every hard-to-place method goes.
A service is more useful when it represents a capability or external dependency:
PaymentGatewayInventoryReservationServiceTaxCalculatorShippingRateProviderInvoiceNumberGenerator
These names communicate behavior and constraints. They are also easier to replace, fake, and test.
A weak service name usually signals weak boundaries. If a class is called OrderService, ask what kind of order behavior it owns. Creating orders? Pricing orders? Fulfilling orders? Cancelling orders? Each may have different transaction rules, dependencies, and failure modes.
Repositories are justified at real persistence boundaries
Repositories are not wrong in Laravel. They are just overused.
A repository can be useful when persistence access is complex, reused, and worth naming. It can also help when the application must hide the storage mechanism from the use case.
Good candidates include:
Read models for dashboards with complex filtering
Search queries backed by a separate engine
Multi-tenant lookup rules
Persistence behind an interface for a package or bounded context
Query logic shared across HTTP, CLI, and queued jobs
Data access where returning Eloquent models would leak too much
A repository is less useful when every method is a one-line wrapper around Eloquent.
DTOs are valuable at boundaries, noisy inside them
DTOs are strongest at application boundaries:
Request to use case
Queue payload to handler
External API response to internal model
Configuration input to a domain operation
Data passed between modules with different owners
They are weaker when used as mandatory replacements for every array, every model attribute set, or every internal method call. In PHP, a DTO has maintenance cost. Each property, constructor argument, mapping method, and test fixture must stay aligned with the application.
Use DTOs when they increase type safety or reduce input ambiguity. Avoid them when they merely duplicate a FormRequest and an Eloquent fillable array with no change in behavior.
A decision table for Laravel layers
Layer | Runtime boundary | Test surface | Useful when | Usually ceremony when |
|---|---|---|---|---|
FormRequest | HTTP input | Low | Validation, authorization, input normalization | Business workflow is placed inside it |
DTO | Input contract | Low to Medium | Data crosses HTTP, queue, CLI, or module boundary | It only mirrors validated request data once |
Action | Use-case execution | Medium | Operation has a business name, transaction, or multiple callers | It wraps one model method with no policy or workflow |
Service | Capability | Medium | External dependency, calculation, coordination, reusable domain operation | It becomes a generic bucket for model-related methods |
Repository | Persistence access | Medium to High | Query rules are complex, reused, or storage must be hidden | It forwards directly to Eloquent and returns the same model |
Job | Async execution | Medium | Work can be retried, delayed, or moved outside request latency | It hides business logic that still must be synchronous |
This table is not a rulebook. It is a pressure test. If adding a layer does not change a boundary, test surface, or operational behavior, it is probably not buying much.
Clean architecture should reduce change cost
The worst version of “clean architecture” in Laravel looks structured from a distance but is fragile in practice:
Controller maps request to DTO.
Action calls service.
Service calls repository.
Repository calls Eloquent.
Tests mock every layer.
A simple field change touches five files.
That design has more seams, but not necessarily better boundaries. It can slow delivery because every change requires deciding where the code “belongs” instead of making the business rule clearer.
A more practical Laravel approach is incremental:
Start with
FormRequest, policies, Eloquent, and clear controller methods.Extract an action when a use case gains workflow, transaction scope, or multiple callers.
Add DTOs when raw input becomes ambiguous or crosses boundaries.
Introduce services for named capabilities, especially external integrations or reusable calculations.
Add repositories only when persistence complexity is real enough to hide or centralize.
Keep return types honest. If a repository returns Eloquent models, do not pretend the ORM is isolated.
Testing should influence the boundary
Testing is one of the clearest signals that a layer is justified.
For simple CRUD, feature tests often give the best return. They validate routing, authorization, validation, persistence, and redirects in one pass. Unit testing a repository wrapper around Product::create() is low value.
For checkout, action-level tests are useful because they can verify business behavior without depending on HTTP details. You can fake payment, freeze inventory state, assert order creation, and test failure paths around transactions.
The goal is not more mocks. The goal is a test shape that matches the risk. If a layer forces tests to mock implementation details rather than verify behavior, the abstraction is probably too thin.
Practical rules for Laravel teams
Use these checks during code review:
Can the class name describe a business operation or capability without mentioning transport?
Does the layer remove knowledge from its caller, or only rename the same call?
Would this class still be useful if the feature were called from a queue or CLI command?
Is the transaction boundary easier to see after extraction?
Does the DTO prevent invalid or ambiguous input, or only duplicate an array?
Does the repository hide persistence complexity, or only wrap Eloquent?
Will a common feature change touch fewer files because this layer exists?
If the answers are mostly negative, keep the code closer to Laravel’s default patterns.
For engineers who work with Laravel architecture in production, the most relevant certification to review is Senior Laravel Developer, especially if your day-to-day work includes application boundaries, testing strategy, and maintainable backend design.
Conclusion
Clean architecture in Laravel is not measured by how many folders sit under app/. It is measured by whether the code makes change safer, testing more targeted, and production behavior easier to reason about.
For basic CRUD, controllers, form requests, policies, and Eloquent are often enough. For checkout-style use cases, an action and DTO can create a meaningful boundary. Services should model capabilities. Repositories should hide real persistence complexity, not copy Eloquent method names.
The practical standard is simple: add a layer when it protects a boundary that already hurts, or one that is clearly about to hurt. Otherwise, write the direct Laravel code and let the architecture grow where the system proves it needs structure.