Our services
If you can think it, we can make it brainsoft.
If you can think it, we can make it brainsoft.
Written By: BrainSoft In Backend
Every Laravel project starts the same way. You run laravel new, get app/Models, app/Http/Controllers, and a handful of service providers, and it feels tidy. Six months later that controller is 400 lines, the same validation lives in three places, and nobody remembers where the invoice total gets recalculated. The framework did not fail you. You just outgrew the default shape.
This is a walkthrough of how I restructure a Laravel app once it stops fitting the defaults: when to add action classes, where DTOs belong, how to split by domain without turning it into a hexagonal architecture essay, and which of these moves are worth the cost. I will show the folders, the code, and the moments where I decided not to bother.
The temptation when you read about project structure is to reorganise everything on a Friday afternoon. Do not. The default folders are fine for a CRUD app with ten models. They start hurting at specific, recognisable points, and those points tell you what to build.
Invoice in billing and Invoice in reporting) and keep colliding. That is a candidate for domain folders.If none of those describe your codebase, stop here. Structure is a response to complexity, not a personality trait.
The single highest-value change I make is extracting write operations into action classes. An action is a class with a handle method that does one thing and returns a result. It lives in app/Actions until the project is big enough to justify domain folders.
namespace App\Actions\Billing;
class CreateInvoice
{
public function __construct(private InvoiceNumberGenerator $numbers) {}
public function handle(CreateInvoiceData $data): Invoice
{
$invoice = Invoice::create([
'number' => $this->numbers->next(),
'customer_id' => $data->customerId,
'due_at' => $data->dueAt,
]);
$invoice->lines()->createMany($data->lines);
return $invoice;
}
}
The controller becomes thin, and the same action can be called from a queued job, an artisan command, or a webhook handler without duplicating logic. That reuse is the whole point. If an action is only ever called from one controller, it is probably ceremony, and I leave the code in the controller.
One rule I keep: actions do not return HTTP responses and do not call redirect(). They return domain results. The controller decides what that means for the response.
Once actions exist, they need typed input. Passing a raw $request->validated() array into five different classes is how you get bugs where one caller spells a key differently. A DTO is a plain class with public readonly properties and a static constructor. No magic, no base class.
namespace App\Data;
final readonly class CreateInvoiceData
{
public function __construct(
public int $customerId,
public CarbonImmutable $dueAt,
public array $lines,
) {}
public static function fromRequest(StoreInvoiceRequest $request): self
{
return new self(
customerId: $request->integer('customer_id'),
dueAt: $request->date('due_at')->toImmutable(),
lines: $request->input('lines', []),
);
}
}
I keep DTOs in app/Data rather than next to the action. They get shared between actions, jobs, and API resources often enough that co-locating them creates awkward imports. If you use Spatie's laravel-data, this folder already has a convention and you should follow it.
Do not convert every request into a DTO. A controller that validates three fields and calls Model::create() does not need one. I add DTOs when the same shape crosses a boundary more than once.
For larger products I move to domain folders. Instead of app/Actions, app/Data, app/Services, everything for billing lives under app/Domain/Billing, with actions, DTOs, enums, and exceptions inside it.
app/
Domain/
Billing/
Actions/
Data/
Enums/
Exceptions/
Reporting/
Actions/
Data/
Http/
Controllers/
Requests/
Models/
I keep Eloquent models in app/Models even in this layout. Moving models into domains sounds clean until you need relationships across domains, and then you are importing App\Domain\Reporting\Models\Invoice from billing and the boundary was fiction anyway. Models stay global. Behaviour moves to domains.
The honest tradeoff: domain folders add navigation cost. New developers spend a week hunting for files. I only do this when there are three or more genuinely separate areas of the product, or when a team is large enough that people are stepping on each other in app/Actions.
None of this is exotic. It is the same shape you would reach for in any language. If you want a second opinion on whether your current structure is worth changing, get in touch and we can look at the actual code rather than a diagram.
No. I extract an action when the logic is reused from more than one entry point, when the method is long enough that testing it through HTTP is painful, or when it needs to run inside a job. A single-use controller method that validates and saves is fine as it is.
They do different jobs. The form request handles HTTP concerns: authorisation, validation rules, and mapping errors to a 422 response. The DTO is the typed shape you pass inward. The request builds the DTO in a fromRequest method, and nothing past the controller sees the request object.
Mostly no. Composer autoloading handles any PSR-4 folder under app/. What you do need to update is anything relying on namespace discovery, such as event and listener registration in older versions, plus any hardcoded namespaces in config or test factories. Run the test suite after each move rather than all at once.