Technical
Laravel Architecture That Stays Small Enough to Maintain
Good architecture should reduce friction for the next person who opens the codebase, including future you six months from now.
A practical look at how to scale and keep a Laravel codebase organized without turning it into a complex maze of unnecessary abstractions.
Back to blogLaravel Architecture That Stays Small Enough to Maintain
When starting a new Laravel project, one of the first decisions I think about is how to structure the application.
Should I use a Service layer? Should every model have a Repository? Should I organize everything by feature? Would Domain-Driven Design (DDD) or Clean Architecture make the project more maintainable?
These are reasonable questions, especially when building applications that might grow beyond their original scope.
But I've learned that adding more architectural layers doesn't automatically make software easier to maintain. Sometimes, it makes a relatively simple application harder to understand.
A project that starts with a handful of CRUD operations can quickly become a maze of interfaces, repositories, services, DTOs, factories, and deeply nested directories.
Instead of focusing on how sophisticated the architecture looks, I prefer to ask a more practical question:
Can I understand, test, and modify this feature without navigating unnecessary layers of code?
This article explains the approach I currently prefer when building Laravel applications: start with Laravel's conventions, keep responsibilities clear, use focused Action classes where they provide value, and introduce more abstraction only when the application actually needs it.
1. Start with Laravel's conventions
Laravel already provides a practical application structure.
A new Laravel application includes conventions for controllers, models, middleware, validation, authorization, events, jobs, and other common application responsibilities.
There is no need to replace all of these conventions before writing the first feature.
For example, consider a simple application that manages projects.
You might initially have:
app/
├── Http/
│ ├── Controllers/
│ │ └── ProjectController.php
│ └── Requests/
│ ├── StoreProjectRequest.php
│ └── UpdateProjectRequest.php
├── Models/
│ └── Project.php
└── Policies/
└── ProjectPolicy.php
For a straightforward CRUD feature, this can be completely reasonable.
The controller receives the request, the Form Request validates the input, the Policy handles authorization, and the Eloquent model interacts with the database.
You don't necessarily need a Service, Repository, interface, or DTO for every operation.
However, as the application grows, you may encounter operations that involve multiple models, database transactions, external integrations, or business rules shared across different entry points.
That's when I consider introducing an Action class.
My starting principle: use Laravel's existing structure until a specific responsibility deserves its own component.
2. Keep controllers focused on HTTP responsibilities
Controllers are the entry points for HTTP requests.
Their responsibilities should generally include receiving validated input, invoking application operations, and returning an appropriate response.
They shouldn't become the place where every business rule is implemented.
Consider a controller that creates a project.
public function store(
StoreProjectRequest $request
) {
$project = Project::create(
$request->validated()
);
return redirect()->route(
'projects.show',
$project
);
}
There is nothing inherently wrong with this implementation.
For a simple CRUD operation, extracting a separate Action class might not provide enough value to justify an additional file.
But what happens when creating a project also requires generating a unique identifier, assigning a default status, creating related records, and recording an activity log?
The controller can quickly become responsible for much more than handling an HTTP request.
Instead of placing all that logic in the controller, I prefer to extract the operation into a dedicated Action.
public function store(
StoreProjectRequest $request,
CreateProject $createProject
) {
$project = $createProject->handle(
$request->validated()
);
return redirect()->route(
'projects.show',
$project
);
}
Now the controller describes what happens from an HTTP perspective without containing the entire project creation workflow.
The Action owns the application operation.
The important distinction is that thin controllers are a useful consequence of separating responsibilities, not a reason to create unnecessary abstractions for every line of code.
3. Use Actions for meaningful application operations
An Action represents a specific operation that the application performs.
Examples include:
CreateProject
UpdateProject
PublishBlog
SubmitInquiry
ApproveTrialTask
AssignUserRole
I prefer names that describe an actual operation rather than generic classes such as ProjectManager or GeneralService.
A focused Action makes it easier to find where a particular workflow is implemented.
For example:
app/
└── Actions/
└── Projects/
├── CreateProject.php
├── UpdateProject.php
└── ArchiveProject.php
A project creation Action might look like this:
namespace App\Actions\Projects;
use App\Models\Project;
use Illuminate\Support\Facades\DB;
class CreateProject
{
public function handle(array $data): Project
{
return DB::transaction(function () use ($data) {
$project = Project::create([
'name' => $data['name'],
'description' => $data['description'] ?? null,
'status' => 'draft',
]);
$project->activities()->create([
'description' => 'Project created',
]);
return $project;
});
}
}
This example assumes that the Project model has an activities() relationship and that its mass-assignment configuration permits the supplied attributes.
The database transaction ensures that the project and its related activity record are committed together.
If either operation fails, the transaction is rolled back.
The Action can also be reused outside an HTTP controller.
For example, a queued job, console command, or another application workflow could invoke CreateProject without duplicating the project creation logic.
This is one of the main reasons I prefer Actions for meaningful business operations.
They provide a clear boundary around a use case while keeping the application close to Laravel's existing conventions.
4. Use DTOs when data needs a clearer contract
When an operation accepts only a few simple values, a validated array can be sufficient.
But as input becomes more complex, passing arrays between multiple components can become difficult to reason about.
Consider this:
$createProject->handle([
'name' => $request->name,
'description' => $request->description,
'owner_id' => $request->user()->id,
]);
The array works, but its structure is implicit.
A Data Transfer Object (DTO) can make the expected input more explicit.
namespace App\Data\Projects;
final readonly class CreateProjectData
{
public function __construct(
public string $name,
public ?string $description,
public int $ownerId,
) {}
}
The controller can construct the DTO after validation:
public function store(
StoreProjectRequest $request,
CreateProject $createProject
) {
$data = new CreateProjectData(
name: $request->validated('name'),
description: $request->validated(
'description'
),
ownerId: $request->user()->id,
);
$project = $createProject->handle($data);
return redirect()->route(
'projects.show',
$project
);
}
The Action now receives a clearly defined input:
public function handle(
CreateProjectData $data
): Project {
return Project::create([
'name' => $data->name,
'description' => $data->description,
'owner_id' => $data->ownerId,
]);
}
DTOs are particularly useful when an operation accepts many related inputs, needs to be called from different application entry points, or benefits from a stable typed contract.
They also help avoid passing the entire HTTP Request object deep into application logic.
However, I don't believe every operation needs a DTO.
For a simple read operation that accepts a single project ID, an integer or model parameter may already communicate everything the Action needs.
Use DTOs when they improve clarity, not because an architectural diagram requires them.
5. Don't introduce a Repository for every Eloquent model
The Repository pattern is frequently discussed when developers begin exploring Clean Architecture and DDD.
A common approach is to create an interface and implementation for every model.
For example:
ProjectRepositoryInterface
↓
EloquentProjectRepository
↓
Project
This can be useful in certain applications, but it also adds another layer between your application logic and Eloquent.
If the repository simply forwards every call to an Eloquent model, it may not provide much value.
Consider:
public function find(int $id): ?Project
{
return Project::find($id);
}
If the repository adds no meaningful behavior or boundary, it becomes another file developers must navigate.
Laravel's Eloquent ORM already provides querying, relationships, persistence, and model-level functionality.
For many applications, allowing Actions to work directly with Eloquent is a practical choice.
public function handle(int $projectId): Project
{
return Project::query()
->with(['owner', 'members'])
->findOrFail($projectId);
}
I would consider introducing a Repository when there is a concrete need for a persistence abstraction.
Examples include:
- A domain layer that must remain independent of Eloquent.
- Complex persistence operations that need a stable interface.
- Multiple data sources behind the same application contract.
- A genuine requirement to replace or isolate the persistence implementation.
A Repository can be valuable when it creates a meaningful boundary. It is less useful when it merely renames Eloquent methods.
You can start with Eloquent and introduce repositories later if the application's complexity justifies them.
6. Keep Services for shared capabilities
Actions and Services don't need to compete with each other.
I think of an Action as a specific application operation, while a Service provides a capability that multiple operations may need.
For example, consider a system that generates invoices.
You might have:
Actions/
└── Invoices/
├── CreateInvoice.php
├── FinalizeInvoice.php
└── CancelInvoice.php
Services/
└── Invoices/
└── InvoiceCalculator.php
The CreateInvoice Action coordinates the workflow.
The InvoiceCalculator Service handles the calculation rules that may also be needed when previewing or updating an invoice.
This avoids duplicating calculation logic across multiple Actions.
The same principle can apply to file processing, external API integrations, pricing calculations, or other shared capabilities.
I generally avoid large Service classes that contain every operation for a feature.
A class such as ProjectService with dozens of methods can eventually become difficult to navigate and test.
Instead, I prefer focused Actions for individual workflows and smaller Services for behavior shared across those workflows.
7. Organize directories around actual complexity
Directory structure matters because it affects how quickly developers can find the code they need.
For a smaller Laravel application, I prefer to keep the structure relatively close to the framework defaults.
For example:
app/
├── Actions/
│ ├── Blogs/
│ ├── Projects/
│ └── Inquiries/
├── Data/
│ ├── Blogs/
│ └── Projects/
├── Http/
│ ├── Controllers/
│ ├── Requests/
│ └── Resources/
├── Models/
├── Policies/
├── Jobs/
└── Services/
This structure provides enough separation to make application operations easy to discover without requiring a deeply nested directory hierarchy.
As a particular feature grows, I may group its related components more closely.
For example:
app/
└── Features/
└── Projects/
├── Actions/
├── Data/
├── Http/
├── Models/
└── Policies/
This can make sense when the Projects feature becomes large enough that navigating across several global directories becomes inconvenient.
However, I wouldn't reorganize the entire application into feature modules simply because one feature has become complex.
Different parts of an application may require different levels of structure.
The purpose of directory organization is to make code easier to locate and maintain, not to enforce symmetry between features that have different requirements.
8. Use Laravel's native tools before building your own abstractions
One of Laravel's strengths is the number of common application concerns it already handles.
Before introducing a custom solution, I check whether the framework already provides the behavior I need.
| Concern | Laravel feature |
|---|---|
| Request validation | Form Requests |
| Authorization | Policies and Gates |
| Database operations | Eloquent and Query Builder |
| Atomic database operations | Database transactions |
| Background processing | Jobs and Queues |
| Application events | Events and Listeners |
| API responses | API Resources |
| Route parameter resolution | Route Model Binding |
| Cross-cutting HTTP concerns | Middleware |
For example, authorization rules belong naturally in a Policy.
public function update(
User $user,
Project $project
): bool {
return $user->id === $project->owner_id;
}
The controller can authorize the operation before invoking the Action:
public function update(
UpdateProjectRequest $request,
Project $project,
UpdateProject $updateProject
) {
$this->authorize('update', $project);
$updateProject->handle(
$project,
$request->validated()
);
return redirect()->route(
'projects.show',
$project
);
}
This keeps the HTTP authorization boundary explicit while the Action focuses on performing the update.
However, an Action that can also be invoked by a command, job, or other entry point must not assume that controller authorization always occurs. Authorization should be enforced at every applicable entry point, or centrally within the use case when the operation requires it.
The same principle applies to validation, transactions, and asynchronous processing.
Laravel already offers a consistent way to handle these concerns.
Using those tools keeps the application familiar to other Laravel developers and reduces the amount of custom infrastructure that needs to be maintained.
9. Make your architecture easy to test
One of the most useful ways to evaluate an architecture is to ask how difficult it is to test a particular behavior.
If testing a business operation requires constructing an HTTP Request, bootstrapping unrelated components, or mocking several unnecessary interfaces, the responsibilities may be too tightly coupled.
With a focused Action, I can test the operation through its actual application boundary.
For example:
use App\Actions\Projects\CreateProject;
use App\Models\Project;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('creates a project', function () {
$project = app(CreateProject::class)->handle([
'name' => 'ShipWithIan',
'description' => 'Portfolio platform',
]);
expect($project)->toBeInstanceOf(Project::class);
$this->assertDatabaseHas('projects', [
'name' => 'ShipWithIan',
'status' => 'draft',
]);
});
This example assumes the Action accepts a validated array and that the model's required relationships and attributes are configured appropriately.
The test focuses on what the operation is supposed to accomplish rather than the internal arrangement of every class.
For business rules that don't require the database, a focused Service or another small component may be tested independently.
I still use feature tests for HTTP behavior, including authentication, authorization, validation, and responses.
The goal is not to eliminate integration tests or mock every dependency. It is to make important behavior easy to verify at the appropriate level.
10. Leave room for Domain-Driven Design when the project needs it
A simple Action-based architecture doesn't prevent an application from evolving toward a more formal domain architecture.
Suppose your application eventually introduces complex workflows involving payments, approvals, contractual obligations, or other business rules that must remain consistent across many operations.
At that point, it may become useful to introduce:
- Domain entities and value objects.
- Domain services.
- Explicit application and infrastructure boundaries.
- Repository interfaces for persistence-independent domain operations.
- Domain events and business invariants.
These concepts can help when the domain itself becomes sufficiently complex.
But I don't think every Laravel application needs to begin with a complete DDD structure.
I would rather introduce those boundaries around the parts of the application that genuinely need them.
For example, a complex billing feature might eventually deserve a dedicated domain model, while a simple portfolio blog can continue using Eloquent and conventional Laravel controllers.
Both approaches can exist within the same application when their responsibilities are clear.
Architecture should evolve in response to real complexity rather than forcing every feature into the most elaborate structure available.
11. The architecture I currently prefer
For many of my Laravel applications, the overall request flow looks like this:
HTTP Request
|
v
Form Request
Validation and authorization
|
v
Controller
|
v
DTO (when useful)
|
v
Action
|
+-- Eloquent models
|
+-- Shared services
|
+-- Database transactions
|
+-- Events / Queued jobs
|
v
Database
|
v
HTTP Response
For read operations, the flow can be simpler:
HTTP Request
|
v
Controller
|
v
Eloquent Query / Query Object
|
v
Model or Collection
|
v
Inertia Props / API Resource
Not every query requires a dedicated Action.
Simple queries can stay close to the controller when doing so keeps the code clear.
More complex queries can be extracted into focused Query objects when they involve reusable filters, substantial data transformations, or complicated retrieval logic.
This approach lets me use Laravel directly while keeping important application workflows discoverable and reusable.
It also gives me room to introduce stronger domain boundaries if a feature eventually requires them.
12. Questions I ask before introducing another abstraction
Before creating a new layer, interface, or architectural component, I try to answer a few questions:
What problem is this abstraction solving?
If I can't identify a concrete problem, I probably don't need the abstraction yet.
Does this responsibility belong somewhere else?
Business rules shouldn't be buried in controllers, but not every line of application logic requires its own class.
Will this improve testing or reuse?
Extracting a component makes sense when it creates a useful boundary, reduces duplication, or makes important behavior easier to verify.
Can another developer find this code quickly?
A predictable, discoverable structure is often more valuable than a theoretically elegant one that requires extensive documentation to navigate.
Can I introduce this abstraction later?
If the application is still small and its requirements are straightforward, I may be able to postpone a more elaborate design until there is enough information to make a better decision.
These questions help me avoid treating architecture as a checklist of patterns that must be implemented before a project can begin.
Final thoughts
The longer I work with Laravel, the more I appreciate architecture that makes everyday development straightforward.
A maintainable application doesn't necessarily have the most layers, the largest number of interfaces, or the most elaborate directory structure.
It has clear responsibilities, predictable behavior, and code that developers can understand and change with confidence.
For my projects, that usually means starting with Laravel's conventions, keeping controllers focused, using Actions for meaningful application operations, introducing DTOs and Services where they provide value, and allowing more formal architectural boundaries to emerge when needed.
There is no single structure that fits every Laravel application.
A small portfolio, a growing CRM, and a complex enterprise system may require different levels of architectural rigor.
The objective is to choose an approach that fits the system you're building today without making tomorrow's changes unnecessarily difficult.
Good architecture should make your application easier to work on, not make every simple feature feel like a new infrastructure project.
I'm documenting the architecture, development decisions, and lessons behind my projects as I continue building and improving them.
Website: ShipWithIan
Projects: Explore my projects