Skip to content
All posts

Engineering5 min read

Laravel File Structure

This guide explains the file structure of a Laravel application that I use and makes sense.

Isaac Hatilima

Full-stack and DevOps engineer

A developer sending a request along a conveyor through a controller arch, a DTO parcel and an action machine into the database, with the project folder tree floating above.

How I Structure My Laravel App (And Why It Keeps Me Sane)

Laravel is so flexible you could spend hours trying to choose a file structure. I settled on a structure optimized for clarity, testability, and future-me not swearing at past-me.

Every request takes the same path through the app: Request → Controller → DTO → Action → Repository. Requests and Controllers are Laravel's own folders; DTOs, Actions and Repositories are the extra ones I add to every Laravel app:

bash
app/├─ Http/│  ├─ Requests      # 1. validate the input│  └─ Controllers   # 2. hand the work off and return the response├─ DTOs             # 3. normalize the data├─ Actions          # 4. run the business logic└─ Repositories     # 5. read and write the database

Let's see the extra directories used in action "pun intended." The article will simulate a user registration request in Laravel API and follow it through each layer in that order.

Request Classes

php
<?phpclass RegisterRequest extends FormRequest{    /**     * @return array<string, list<string|ValidationRule>>     */    public function rules(): array    {        /** @var ValidationRule $passwordRule */        $passwordRule = Password::min(8)            ->letters()            ->mixedCase()            ->numbers()            ->symbols();        return [            'first_name' => ['required', 'string', 'min:2', 'max:255'],            'last_name' => ['required', 'string', 'min:2', 'max:255'],            'email' => ['required', 'string', 'lowercase', 'email:rfc,dns', 'max:255', 'unique:'.User::class],            'username' => ['required', 'string', 'min:2', 'max:255', 'unique:'.User::class],            'password' => [                'required',                $passwordRule,            ],        ];    }    public function authorize(): bool    {        return true;    }}

The request class can be as simple or as expressive as you need it to be, but in most cases, a setup like the one above is more than enough.

Its responsibility is narrow and intentional: validate incoming data and nothing else. There is no business logic here, no database access, this is not to say you will NEVER see queries in Request Classes, and no side effects. If the request passes validation, the rest of the system can safely assume the data is well-formed.

This also creates a clear contract: anything that reaches the dto layer has already been checked for shape, type, and basic constraints. That keeps validation concerns out of controllers and prevents actions from turning into defensive programming exercises.

Controller Classes

php
<?phpclass RegisterController extends Controller{    public function __construct(        private readonly RegisterUserAction $registerAction    ) {    }        /**     * @throws Throwable     */    public function store(RegisterRequest $request): JsonResponse    {        $dto = RegisterRequestDTO::fromRequest($request);        $result = $this->registerAction->execute($dto);        /** @var User $user */        $user = $result->user;        return $this->authSuccessResponse(            $request,            $user,            (string) $result->token        );    }}

In the snippet above, the controller is intentionally kept thin and is only responsible for request handling and response formatting. By the time store runs, RegisterRequest has already validated the input, so all that is left is to turn it into a DTO and hand it to the action.

The RegisterUserAction is injected via the constructor as a class dependency. That said, it could just as easily be injected directly into the method that needs it. That kind of choice exists in software engineering. In mathematics, 5 + 5 has one equation, no alternatives, no debate.

DTO Classes

php
<?phpfinal readonly class RegisterRequestDTO{    public function __construct(        public string $firstName,        public string $lastName,        public string $email,        public string $username,        public string $password,    ) {    }    public static function fromRequest(RegisterRequest $request): self    {        return new self(            firstName: trim(Str::title($request->string('first_name')->value())),            lastName: trim(Str::title($request->string('last_name')->value())),            email: trim(strtolower($request->string('email')->value())),            username: trim($request->string('username')->value()),            password: trim($request->string('password')->value()),        );    }}

The DTO is responsible for shaping and normalizing incoming data. This includes string transformations such as converting values to a title case, lowercasing emails, or applying any other input normalization required by the system.

By the time data leaves the DTO, it is already consistent and predictable. Every downstream layer can rely on this shape without rechecking or reformatting values.

I deliberately split DTOs into two categories: Command DTOs for incoming data and Read DTOs for outbound responses. Command DTOs assume zero trust in the client. All inputs are trimmed and normalized at the boundary, ensuring the rest of the application never has to care where the data originated.

This keeps intent clear: requests validate, DTOs normalize, and actions execute.

Action/Service Classes

php
<?phpfinal readonly class RegisterUserAction{    public function __construct(        private UserRepository $userRepository    ) {    }    /**     * @throws Throwable     */    public function execute(RegisterRequestDTO $dto): User    {        $user = DB::transaction(function () use ($dto): User {            $user = $this->userRepository->create([                'email' => $dto->email,                'username' => $dto->username,                'password' => Hash::make($dto->password),            ]);            $user->profile()->create([                'first_name' => $dto->firstName,                'last_name' => $dto->lastName,            ]);            $user->userStat()->create();            return $user;        });        SendVerificationEmailJob::dispatch($user);        return $user;    }}

Action (or service) classes are responsible for business logic and orchestration. They define what happens when a use case is executed, not how the framework works.

Any database interaction is delegated to repositories. When an action needs to persist or retrieve data, it does so through an explicit repository method rather than embedding query logic directly. This keeps actions readable and prevents persistence details from leaking into workflow code.

Actions are free to coordinate multiple steps: transactions, related model creation, dispatching jobs, issuing tokens, or updating the state. They are also framework-agnostic by design: there is no request, no response, and no assumption about where the action is being called from.

The return type depends on the use case. An action may return a value object, a DTO, a primitive, or nothing at all. What matters is that the outcome is explicit and intentional, not implied through side effects.

This layer is where the application’s behavior lives, and where it is easiest to test and reason about it in isolation.

Repository Classes

php
<?phpfinal readonly class UserRepository{    public function findByEmail(string $email): ?User    {        return User::query()            ->where('email', $email)            ->first();    }    public function findByPublicId(string $publicId): ?User    {        return User::query()            ->where('public_id', $publicId)            ->first();    }    /**     * @param array<string, mixed> $criteria     *     * @return Collection<int, User>     */    public function findMultiple(array $criteria): Collection    {        return User::query()            ->where($criteria)            ->get();    }    /**     * @param array<string, mixed> $data     */    public function create(array $data): User    {        return User::query()->create($data);    }    /**     * @param array<string, mixed> $data     */    public function update(User $user, array $data): User    {        $user->update($data);        return $user->refresh();    }    public function delete(User $user): bool    {        return (bool) $user->delete();    }}

Repositories act as the application’s persistence boundary. They encapsulate all direct interaction with the database and expose a small, intention-revealing API to the rest of the system.

Rather than scattering query logic across controllers, actions, and jobs, all user-related persistence concerns live in the UserRepository. Actions ask what they want to do (find a user, create a user, update the state) without caring how those operations are implemented.

Each method represents a meaningful operation:

  • findByEmail and findByPublicId express intent clearly
  • findMultiple handles simple querying
  • create, update, and delete centralize write behavior

This approach keeps queries consistent, makes refactoring safe, and prevents subtle differences in how data is accessed throughout the codebase. If persistence rules change, there is a single place to update them.

Most importantly, repositories keep business logic focused on behavior rather than database mechanics, reinforcing the separation between what the application does and how data is stored.

Bonus: Type Safety Without Fighting Laravel (Model)

php
<?php/** * @property string $id * @property string $public_id * @property string $email * @property string $username * @property string|null $password * @property string|null $remember_token * @property bool $is_active * @property CarbonInterface|null $email_verified_at * @property CarbonInterface|null $last_login_at * @property CarbonInterface|null $created_at * @property CarbonInterface|null $updated_at * * @property-read Profile $profile */#[Fillable([    'username',    'email_verified_at',    'email',    'password',    'last_login_at',    'is_active',])]final class User extends Authenticatable{    use HasUuids;    /**     * @return HasOne<Profile, $this>     */    public function profile(): HasOne    {        return $this->hasOne(Profile::class);    }}

The class follows two newer conventions. Mass-assignable columns are declared with the #[Fillable] attribute, which is how Laravel 13 does it instead of a $fillable property. And HasUuids gives every user a UUID primary key instead of an auto-incrementing integer, which is why $id is a string. Laravel generates ordered UUIDv7 values, so the index on the key stays efficient.

A UUID key has to be a UUID everywhere it is stored: create the column with $table->uuid('id')->primary(), use foreignUuid('user_id') on tables that point at users, such as profiles and sessions, and change morphs('tokenable') to uuidMorphs('tokenable') in Sanctum's tokens migration.

Laravel’s dynamic nature is powerful, but it comes at a cost: your IDE and static analysis tools can only help you as much as your types allow.

This is where model-level PHPDoc annotations earn their keep.

These annotations don’t change runtime behavior, but they dramatically improve developer experience.

They allow:

  • IDE auto-completion to actually work
  • Static analysis tools (PHPStan) to reason about your models
  • Fewer “is this nullable?” guesses while coding

More importantly, they make intent explicit. When a property is nullable, it’s clear. When a relationship returns a collection, it’s documented. When a method exists via traits or macros, it’s discoverable.

This approach pairs naturally with DTOs and repositories. Your domain stays expressive, your tooling stays helpful, and you get type safety benefits without trying to force Laravel into something it isn’t.

Laravel will always be dynamic. The goal isn’t to remove that, it’s to put guardrails around it.

Written by Isaac Hatilima

I build and run products across web, mobile and infrastructure.

More about me

Planning a release like this?

Tell me what you are working on. We’ll agree on the scope, and I’ll build it and keep it running.

Book a call