Skip to content

Laravel Code Organisation Guide

This guide defines the purpose, responsibilities, and boundaries for each type of file in a Laravel application. Follow these rules to keep your codebase organised, testable, and scalable.


Why we need them

Controllers handle HTTP requests and responses, acting as the entry point for your application’s logic. They coordinate between the web layer (requests/responses) and your business/domain layer (services). They can interact directly with repositories for simple CRUD operations. No business logic belongs in the controller or repository.

What they SHOULD contain:

  • HTTP-specific logic (e.g., redirects, response formatting, route handling)
  • Request validation (via Form Requests)
  • Calling service methods with validated data
  • Returning responses (JSON for APIs, views for web)
  • Basic error handling for HTTP-related issues

What they SHOULD NOT contain:

  • Business logic (move to services)
  • Database operations (use repositories)
  • Complex validation (use Form Requests)
  • Direct model interactions (use repositories or services)

Good Example:

public function show(Post $post, PostService $postService)
{
return Inertia::render('Posts/Show', [
'post' => Inertia::lazy(fn () => $postService->getPostWithAuthor($post->id)),
'relatedPosts' => Inertia::defer(fn () => $postService->getRelatedPosts($post->id)),
]);
}
public function store(StorePostRequest $request): RedirectResponse
{
$post = $this->postService->createPost($request->validated());
return redirect()->route('posts.show', $post);
}

Bad Example:

public function store(StorePostRequest $request): RedirectResponse
{
// Business logic mixed with controller!
if ($request->user()->isAdmin()) {
$post = $repository->create($request->validated());
} else {
$post = $repository->createForUser($request->user(), $request->validated());
}
return redirect()->route('posts.show', $post);
}

Why we need them

Form Requests centralise validation and authorisation logic, keeping controllers clean and making validation reusable across multiple endpoints.

What they SHOULD contain:

  • Validation rules for incoming data
  • Authorisation logic (e.g., authorize())
  • Custom error messages
  • After-validation hooks (e.g., after())

What they SHOULD NOT contain:

  • Business logic
  • Database operations
  • Response handling (that’s the controller’s job)

Example:

public function rules(): array
{
return [
'title' => 'required|string|max:255',
'content' => 'required|string',
];
}

Why we need them

Services encapsulate business logic, making the application more testable and reusable. They coordinate between repositories, external APIs, and other services.

What they SHOULD contain:

  • Business rules and workflows
  • Coordination between multiple repositories/services
  • Transaction management (e.g., database transactions)
  • Event dispatching
  • Complex calculations or validations

What they SHOULD NOT contain:

  • HTTP-specific logic (controllers handle this)
  • Direct database queries (use repositories)
  • Request/response handling

Example:

public function createPost(array $data): Post
{
DB::transaction(function () use ($data) {
$post = $this->postRepository->create($data);
$this->notificationService->sendWelcomeEmail($post->user);
event(new PostCreated($post));
return $post;
});
}

Why we need them

Repositories abstract database operations, making your application database-agnostic and easier to test. They provide a clean API for data access. They allow us to optimise queries more easily, move to raw database queries for efficiency, and manage caching.

What they SHOULD contain:

  • All database queries (via Eloquent or Query Builder)
  • Query customisation (e.g., paginate(), findBySlug())
  • Relationship loading (eager/lazy loading)
  • Caching (if needed)

What they SHOULD NOT contain:

  • Business logic (that’s the service’s job)
  • HTTP logic (controllers handle this)
  • Validation (use Form Requests)

Example:

public function paginateCompetitionsForAdmin($perPage = 10, ?string $search = null)
{
return Competition::query()
->with(['favouriteImages'])
->when($search, fn($q) => $q->where('title', 'like', "%{$search}%"))
->orderByRaw('CASE status WHEN ? THEN 0 WHEN ? THEN 1 ELSE 2 END', [
CompetitionStatus::Open->value,
CompetitionStatus::Upcoming->value,
])
->paginate($perPage);
}

Why we need them: Models represent your data and define business logic related to a specific entity. They should be thin and focused on data structure.

What they SHOULD contain:

  • Database table definitions ($table, $fillable, etc.)
  • Relationships (hasMany(), belongsTo(), etc.)
  • Accessors/mutators
  • Scopes (e.g., scopeActive(), scopePublished())
  • Basic validation (e.g., $rules in older Laravel versions)

What they SHOULD NOT contain:

  • Complex business logic (use services)
  • Query logic (use repositories)
  • HTTP logic (controllers handle this)

Example:

class Post extends Model
{
protected $fillable = ['title', 'content', 'user_id'];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function scopePublished($query)
{
return $query->where('published_at', '<=', now());
}
}

Why we need them: Jobs handle asynchronous or queued tasks, offloading work from the main request/response cycle.

What they SHOULD contain:

  • A single, focused task (e.g., sending an email, processing a file)
  • Logic for retrying failed attempts
  • Validation of input data

What they SHOULD NOT contain:

  • Business logic that belongs in services
  • Complex workflows (break into multiple jobs)
  • HTTP-specific logic

Example:

class ProcessPodcast implements ShouldQueue
{
public function handle(AudioProcessor $processor)
{
$processor->process($this->podcast);
}
}

7. Events & Listeners (app/Events/ & app/Listeners/)

Section titled “7. Events & Listeners (app/Events/ & app/Listeners/)”

Why we need them: Events and listeners implement the Observer pattern, allowing decoupled communication between different parts of your application.

What they SHOULD contain:

  • Events: Simple data containers (DTOs) representing something that happened
  • Listeners: Logic to respond to events (e.g., sending notifications, updating caches)

What they SHOULD NOT contain:

  • Complex business logic (use services)
  • Direct model interactions (use repositories)

Example:

// Event
class PostCreated
{
public function __construct(public Post $post) {}
}
// Listener
class SendPostCreatedNotification
{
public function handle(PostCreated $event)
{
$event->post->user->notify(new PostCreatedNotification($event->post));
}
}

Why we need them: Service providers bootstrap your application, binding interfaces to implementations and registering services.

What they SHOULD contain:

  • Service container bindings (bind(), singleton())
  • Event listener registrations
  • Package configurations

What they SHOULD NOT contain:

  • Business logic
  • Database operations
  • Request/response handling

Example:

public function register()
{
$this->app->bind(
PostRepositoryInterface::class,
EloquentPostRepository::class
);
}

Why we need them: Policies handle authorisation logic for specific models, keeping your controllers clean.

What they SHOULD contain:

  • Authorisation rules for model actions (e.g., update(), delete())
  • User role/permission checks

What they SHOULD NOT contain:

  • Business logic (use services)
  • Database operations (use repositories)

Example:

public function update(User $user, Post $post)
{
return $user->id === $post->user_id;
}

10. Interfaces (usually app/Services/Interfaces)

Section titled “10. Interfaces (usually app/Services/Interfaces)”

Why we need them

Interfaces enable different logic to be injected into the application based on specific conditions. While they’re often unnecessary, they become invaluable when you need distinct behaviour depending on context — such as displaying different logic for logged-in versus anonymous users. Combined with service provider bindings, interfaces provide an elegant and maintainable solution for conditional logic.

What they SHOULD contain:

  • All methods that should be shared between the classes
  • Typed parameters and return values - use DTO if required

What they SHOULD NOT contain:

  • Any methods specific to a single context

Example:

interface CartManagerInterface
{
/**
* Add an item to the cart.
*/
public function add(
Competition $competition,
Answer $answer,
int $quantity,
?int $maxQuantity = null
): void;
/**
* Replace all items in the cart with the given lines.
*
* @param iterable<array-key, array{competition_id: string|int, answer_id: string|int, quantity: int}> $lines
*/
public function replace(iterable $lines): void;
/**
* Update the quantity of a specific competition in the cart.
*/
public function updateQuantity(Competition $competition, int $quantity): void;
/**
* Remove a specific competition from the cart.
*/
public function remove(Competition $competition): void;
/**
* Clear all items from the cart.
*/
public function clear(): void;
/**
* Get the quantity of a specific competition in the cart.
*/
public function quantityFor(Competition $competition): int;
/**
* Get the cart contents.
*
* @return array{lines: list<array<string, mixed>>, subtotal: float, item_count: int, total_quantity: int}
*/
public function contents(): array;
/**
* Get the raw lines of the cart.
*
* @return array<string, array{competition_id?: string, answer_id?: string, quantity?: int, updated_at?: string}>
*/
public function rawLines(): array;
/**
* Get a summary of the cart.
*
* @return array{item_count: int, total_quantity: int, subtotal: float}
*/
public function summary(): array;
}

  1. Single Responsibility: Each file should do one thing well.
  2. Separation of Concerns: Keep HTTP, business logic, and data access separate.
  3. Testability: Your business logic (services) should be easy to test without HTTP or database dependencies.
  4. Reusability: Services and repositories should be reusable across your application.
  5. Maintainability: Clear boundaries make the codebase easier to understand and modify.