We have spent the previous chapters building the domain layer: DTOs, actions, models, states. Now it is time to look at the other side of the coin: the application layer that consumes the domain and exposes it to end users.
What belongs in the application layer?
An application is the delivery mechanism for your domain. Every Laravel project already has at least two: the HTTP application and the console (Artisan). But there can be more — an instructor dashboard, a student portal, a REST API, a webhook handler. Each is a separate application consuming the same domain code.
The application layer includes:
- Controllers
- Form requests
- Middleware
- Resources
- View models
- HTTP query builders (the ones that parse URL parameters)
- Jobs
The application layer's job is structural, often even boring. It receives input, transforms it for the domain, calls the domain, and presents the output. All the complex logic lives in the domain.
Application modules
Just like domain code is grouped by business concept, application code should be too.
Here is a flat structure that becomes unmanageable in large projects:
App/Dashboard/
├── Controllers/ # 50+ controllers mixed together
├── Requests/ # 30+ request classes
├── Resources/ # 40+ resource classes
└── ViewModels/ # 25+ view models
When you work on courses, you open the Controllers directory and scroll through dozens of unrelated files. Course controllers are mixed with student controllers, enrollment controllers, and everything else.
The solution is application modules — group application code by feature:
App/Dashboard/Courses/
├── Controllers/
│ ├── CoursesController.php
│ ├── CourseStatusController.php
│ ├── DraftCoursesController.php
│ └── PublishCourseController.php
├── Filters/
│ ├── CourseCategoryFilter.php
│ ├── CourseStatusFilter.php
│ └── CourseDifficultyFilter.php
├── Middleware/
│ ├── EnsureValidCourseSettingsMiddleware.php
│ └── EnsureInstructorOwnershipMiddleware.php
├── Queries/
│ ├── CourseIndexQuery.php
│ └── LessonIndexQuery.php
├── Requests/
│ └── CourseRequest.php
├── Resources/
│ ├── CourseResource.php
│ ├── LessonResource.php
│ └── CourseDraftResource.php
└── ViewModels/
├── CourseIndexViewModel.php
├── CourseDraftViewModel.php
└── CourseStatusViewModel.php
When you are working on courses, everything you need is in one place. No scrolling through unrelated files.
Application modules do not need to map one-to-one with domains. A "Dashboard" module might aggregate data from several domains at once. Group application code by what makes sense from the user's perspective, not by the domain structure.
For cross-cutting concerns like a base request class or shared middleware, use the Support namespace.
View models
Controllers should be thin. They receive a request, call the domain, and return a response. But preparing data for views often requires combining multiple data sources, and that logic does not belong in the controller.
View models encapsulate all the data a view needs:
class CourseFormViewModel
{
public function __construct(
private Instructor $instructor,
private ?Course $course = null,
) {}
public function course(): Course
{
return $this->course ?? new Course();
}
public function categories(): Collection
{
return Category::visibleTo($this->instructor)->get();
}
}
Both the create and edit controller methods can use the same view model:
class CoursesController
{
public function create()
{
$viewModel = new CourseFormViewModel(current_instructor());
return view('courses.form', $viewModel);
}
public function edit(Course $course)
{
$viewModel = new CourseFormViewModel(current_instructor(), $course);
return view('courses.form', $viewModel);
}
}
If the view model implements Arrayable, its methods become direct view variables — you can use $course and $categories in the Blade template instead of $viewModel->course().
If it implements Responsable, you can return it directly from a controller as JSON, which is useful for AJAX-driven forms.
View models vs view composers
Laravel's view composers register data globally for specific views:
View::composer('courses.show', CourseComposer::class);
The problem? When you look at a controller, you cannot tell which variables the view receives. The data comes from a globally registered composer that could be defined anywhere.
In small projects this is fine. In large projects with thousands of lines of code and multiple developers, implicit global state is a maintenance nightmare. View models are explicit — you see exactly what data is available right from the controller.
HTTP query builders
Listing pages with filtering, sorting, and pagination are everywhere in large applications. These pages read HTTP query parameters and transform them into Eloquent queries.
Instead of configuring this in controllers:
class CoursesController
{
public function index()
{
$courses = QueryBuilder::for(Course::class)
->allowedFilters('title', 'instructor')
->allowedSorts('title')
->get();
}
}
Extract it into a dedicated query class:
namespace App\Dashboard\Courses\Queries;
use Spatie\QueryBuilder\QueryBuilder;
class CourseIndexQuery extends QueryBuilder
{
public function __construct(Request $request)
{
$query = Course::query()
->with([
'instructor.profile',
'lessons.resources',
]);
parent::__construct($query, $request);
$this
->allowedFilters('title', 'instructor')
->allowedSorts('title');
}
}
Since the request is injected via the constructor, the container autowires it. You can inject the query builder directly into controller methods:
class CoursesController
{
public function index(CourseIndexQuery $courseQuery)
{
$courses = $courseQuery->get();
// ...
}
}
The query builder is reusable across controllers. A PublishedCoursesController can add a scope on top:
class PublishedCoursesController
{
public function index(CourseIndexQuery $courseQuery)
{
$courses = $courseQuery
->wherePublished()
->get();
}
}
Same filters and sorting configuration, but limited to published courses. No duplication.
Jobs belong here
Jobs are similar to controllers. Both receive input, process it, and produce output. Controllers receive HTTP requests; jobs receive serialized data from a queue. Controllers use middleware; jobs use middleware too. Controllers return HTTP responses; jobs write to the database, send emails, or trigger other operations.
Just like controllers, jobs should be thin. They manage queue infrastructure — retry logic, concurrency, delays — and delegate business logic to domain actions:
class SendEnrollmentConfirmationJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(private Enrollment $enrollment) {}
public function handle(SendEnrollmentEmailAction $sendEnrollmentEmailAction): void
{
$sendEnrollmentEmailAction->execute($this->enrollment);
}
}
For simple cases where a job just calls one action, you can skip the job class entirely using packages like spatie/laravel-queueable-action:
$sendEnrollmentEmailAction
->onQueue()
->execute($enrollment);
This dispatches the action as a queued job behind the scenes. No boilerplate job class needed.
Putting it all together
The full picture looks like this:
- A controller receives an HTTP request
- A form request validates the input
- The controller maps validated data to a DTO
- The DTO is passed to a domain action
- The action uses models, states, and other domain classes to execute business logic
- The controller uses a view model or resource to present the result
Each piece has a single responsibility. Each piece is independently testable. And when something needs to change, you know exactly where to look.
The application layer is the glue between your users and your domain. Keep it thin, keep it organized by feature, and let the domain handle the heavy lifting.
For a deeper dive into writing clean controllers, form requests, and dependency injection patterns within the application layer, see Clean Code in Laravel. Its chapters on Naming Conventions and Dependency Injection are especially relevant when structuring application modules.
Summary
- The application layer is the delivery mechanism for your domain. It includes controllers, form requests, middleware, resources, view models, and jobs. Its job is structural: receive input, call the domain, present output.
- Application modules group application code by feature (Courses, Students) instead of by type (Controllers, Requests). This keeps everything related to one feature in one place.
- View models encapsulate all the data a view needs. They are explicit — you see exactly what data is available from the controller — unlike view composers which register data globally and implicitly.
- HTTP query builders extract filtering, sorting, and pagination logic from controllers into dedicated classes. They extend Spatie's
QueryBuilderand are autowired via the container. - Jobs belong in the application layer. Like controllers, they manage infrastructure (retry logic, concurrency, delays) and delegate business logic to domain actions.
- The full flow: controller receives request, form request validates, controller maps to DTO, DTO goes to domain action, action uses models and states, controller presents result via view model or resource.
References
- spatie/laravel-view-models — Spatie, GitHub
- spatie/laravel-query-builder — Spatie Documentation
- spatie/laravel-queueable-action — Spatie, GitHub
- Controllers — Clean Code in Laravel
- Naming Conventions — Clean Code in Laravel
- Dependency Injection — Clean Code in Laravel