Laravel File Structure
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.
These are the extra folders I add to every Laravel app:
app/ ├─ Actions ├─ DTOs └─ Repositories
Let's see the extra directories used in action "pun intended." The article will simulate a user registration request in Laravel API.
Controller Classes
<?php class 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.
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.
Request Classes
<?php class 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.
DTO Classes
<?php final 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 final readonly class RegisterUserAction { public function __construct( private UserRepository $userRepository ) { } /** * @throws Throwable */ public function execute(RegisterRequestDTO $dto): TwoFactorAuthDTO { $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); $token = $user->createToken('auth_token')->plainTextToken; $this->userRepository->update($user, [ 'last_login_at' => now(), ]); return new TwoFactorAuthDTO($user, $token); } }
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 final 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:
findByEmailandfindByPublicIdexpress intent clearlyfindMultiplehandles simple queryingcreate,update, anddeletecentralize 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 /** * @property int $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 */ final class User extends Authenticatable { /** * @var list<string> */ protected $fillable = [ 'username', 'email_verified_at', 'email', 'password', 'last_login_at', 'is_active', ]; /** * @return HasOne<Profile, $this> */ public function profile(): HasOne { return $this->hasOne(Profile::class); } }
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.
