How I Structure Projects: Vertical Slices Over Layers
Most projects I’ve worked on are organized by what code is — controllers together, models together, services together. It’s a familiar layout. The problem is that a single feature is spread across all of it.
If I want to understand how an invoice gets created and emailed, I’m opening InvoiceController, then InvoiceService, then InvoiceRepository, then InvoiceMail, then back to an event listener somewhere else. That’s five locations for one feature. I’d rather they were all in the same place.
Vertical slice architecture does that. Instead of grouping by technical layer, you group by feature. Everything that belongs to a feature lives together.
What this looks like in Laravel
In a Laravel project I nest under app/Domain/, then the feature name, then subfolders once there are enough files of a given kind to warrant them.
app/
Domain/
Invoicing/
Controllers/
InvoiceController.php
Requests/
CreateInvoiceRequest.php
UpdateInvoiceRequest.php
Jobs/
SendInvoiceJob.php
Mail/
InvoiceMail.php
InvoiceService.php
Payments/
Controllers/
PaymentController.php
Requests/
ProcessPaymentRequest.php
PaymentService.php
RefundService.php
Auth/
Controllers/
LoginController.php
ResetPasswordController.php
Requests/
ForgotPasswordRequest.php
The subfolders aren’t pre-emptive. If a feature has one controller and one request, they stay flat in the feature folder. The grouping is a response to the files that actually exist, not a scaffold for files that might.
Controllers stay thin
A controller’s job is to receive a request and return a response. Business logic doesn’t belong here.
// app/Domain/Invoicing/Controllers/InvoiceController.php
class InvoiceController extends Controller
{
public function __construct(
private readonly InvoiceService $invoices,
) {}
public function store(CreateInvoiceRequest $request)
{
$invoice = $this->invoices->create($request->validated());
return response()->json($invoice, 201);
}
}
The request class handles validation. The service handles the work. If a controller action is getting long, something has leaked into it that shouldn’t be there.
Request classes carry the validation
FormRequest classes encode what valid input looks like for a given operation. I use them for every non-trivial endpoint.
// app/Domain/Invoicing/Requests/CreateInvoiceRequest.php
class CreateInvoiceRequest extends FormRequest
{
public function rules(): array
{
return [
'client_id' => ['required', 'exists:clients,id'],
'line_items' => ['required', 'array', 'min:1'],
'line_items.*.description' => ['required', 'string'],
'line_items.*.amount' => ['required', 'numeric', 'min:0'],
'due_at' => ['required', 'date', 'after:today'],
];
}
public function authorize(): bool
{
return $this->user()->can('create', Invoice::class);
}
}
Authorization lives here too. By the time the controller action runs, the request is already validated and authorized.
Services hold the logic
The service is a plain PHP class. No interface, no repository in front of it, unless there’s a specific reason for one.
// app/Domain/Invoicing/InvoiceService.php
class InvoiceService
{
public function create(array $data): Invoice
{
$invoice = Invoice::create([
'client_id' => $data['client_id'],
'due_at' => $data['due_at'],
'status' => 'draft',
]);
foreach ($data['line_items'] as $item) {
$invoice->lineItems()->create($item);
}
$invoice->refresh();
SendInvoiceJob::dispatch($invoice);
return $invoice;
}
}
Eloquent is called directly. No InvoiceRepositoryInterface with an EloquentInvoiceRepository behind it. That pattern exists so you can mock data access in tests, or swap storage implementations. Both are valid reasons, but I don’t write many tests, and I’m not swapping out Eloquent. An abstraction I’m not using is just indirection I have to read through every time.
I do use interfaces when there are two real implementations, or when a dependency crosses into something external where I don’t want the concrete class leaking into the caller.
// Two real implementations — the interface earns its place
interface DocumentStorage
{
public function put(string $path, string $contents): void;
public function get(string $path): string;
}
class S3DocumentStorage implements DocumentStorage { ... }
class LocalDocumentStorage implements DocumentStorage { ... }
// One implementation, no tests — the interface adds nothing
interface InvoiceRepositoryInterface
{
public function create(array $data): Invoice;
}
class EloquentInvoiceRepository implements InvoiceRepositoryInterface
{
public function create(array $data): Invoice
{
return Invoice::create($data);
}
}
The structure changes per language
The Laravel layout above is specific to Laravel. The principle, keep feature code together — applies everywhere, but how it looks depends on the language and framework.
Some frameworks don’t give you much choice. Next.js ties the folder structure to the router, you put files where the framework expects them, not where the principle would suggest. In those cases I just follow the framework. Imposing a feature-grouping structure on top of a convention-driven one creates more friction than it removes.
In Go, there are no classes. The unit of organization is the package, and Go has its own conventions around naming and exported identifiers.
internal/
invoicing/
handler.go
service.go
model.go
payments/
handler.go
service.go
model.go
The files shift to match the language. The goal in each case is the same: when I’m working on invoicing, I want to be in one place. What that place looks like depends on where I’m working.
Where shared code lives
The feature boundary doesn’t mean no shared code. Models, base classes, and shared utilities can live at the app root. What belongs in the slice is the code that only exists because of that feature: the controller, the request, the service, the jobs, the mail. Shared concerns like authentication middleware and error handling stay global.
The limitations
A few things genuinely get harder with this structure.
Shared logic — if several features need the same calculation or behavior, it has to live outside any single slice. It becomes a shared service at the root, or you end up with copies that drift apart until someone notices.
Larger teams — two people working in adjacent feature areas can solve the same problem in different ways independently. A layered structure makes those overlaps easier to spot because similar things are grouped together. Here, you have to be more deliberate about it.
Onboarding — in a layered structure, all controllers are in Controllers/. Someone unfamiliar with the codebase can find them without knowing the domain. Here, you need to know you’re looking for something invoice-related before you land in the right folder.
Testing — calling Eloquent directly in a service makes mocking harder. When I do write tests I use a real database with factory data, which works, but it’s slower and requires more setup. That’s a real tradeoff.
Why I use it anyway
A change to a feature should touch as few locations as possible. This structure makes that the default.
The controllers, request classes, and services aren’t unique to vertical slices. They are useful separations of concern in any layout. What the feature grouping adds is that they’re all findable in one place. I’m not holding a mental map of which layer contains which piece of which feature.
Abstractions get added when there’s a concrete reason for them.
Read Next