One of the most common complaints as a Laravel project grows is that controllers end up becoming massive classes that do everything: validating data, executing business rules, firing events, and formatting responses. In this article, we look at how to avoid this problem using three complementary tools: the Action pattern to encapsulate use cases, Concerns (Traits) to reuse validations and cross-cutting behaviors, and Service classes to group related business operations. All with real code examples—no empty theory.
Action Pattern in Laravel: Lightweight Controllers and Concerns
Content Index
- Action Pattern in Laravel: Lightweight Controllers and Concerns
- The Problem of Bloated Controllers
- Architectural Advantages of Actions
- Practical Implementation and Dependency Injection
- The Structure of the Transactional Action
- Action Pattern to Unburden Controllers
- Organization and Decoupling with Concerns (Traits)
- Why Not Use Form Requests Inside an Action?
- Key Difference Between an Action and a Concern
- Modularization in Laravel: Actions, Concerns, and Helpers in Endpoints
- Modular Architecture: Decoupling Tools
- Case Study: Logic Reuse Between API and Views
- Services in Laravel: What They Are, When to Use Them, and When to Avoid Them
- 1. What is a Service in Laravel
- 2. Why They Are Important
- 3. Anatomy and Difference from Other Pieces (like Actions)
- 4. The Service Container and Binding
- 5. When TO Use a Service
- 6. When NOT to Use a Service (Over-engineering)
- 7. Common Implementation Errors
- 8. Decision Checklist
- 9. Complete Example with Interface and Test
An Action is a class designed under the Single Responsibility Principle. Its objective is to encapsulate a specific use case or a complex business rule that requires multiple operational steps, keeping them in a single coherent and reusable place.
By convention, these classes are named with a verb describing the action to be performed, followed by the resource it operates on. For example: ProcessPayment, CreateUser, or UpdateCourse.
In the previous post, we talked about the importance of getting certified in Laravel and this article comes in handy, as it covers patterns widely used in real Laravel projects.
The Problem of Bloated Controllers
When processing a complex operation—such as recording a payment on an educational platform—the system must execute a transactional sequence of chained steps:
- Verify the user's account status.
- Connect to an external payment gateway.
- Record the transaction in the local database.
- Deduct stock or update course access.
- Trigger system events and send notification emails.
Placing all this logic inside a controller's store method quickly bloats it and hinders long-term maintenance. The Action pattern solves this problem by extracting that operational burden and keeping controllers thin (thin controllers): classes that solely orchestrate input and output, delegating the real work to other specialized layers.
Architectural Advantages of Actions
- Context Agnosticism: An Action does not depend on an HTTP request. It can be invoked from a REST API controller, a hybrid web application (with Vue or Inertia), an
Artisanconsole command, a queuedJob, or aServiceProvider. This flexibility makes it genuinely reusable without needing adaptations. - Ease of Unit Testing: Being isolated from the web infrastructure, the business logic becomes highly testable through pure unit tests. This reduces the need for HTTP request or session mocks, considerably simplifying the testing process and making test suites faster and more reliable.
Practical Implementation and Dependency Injection
Thanks to Laravel's service container, Action classes can be directly injected as dependencies into controller methods, taking advantage of the framework's automatic resolution (autowiring). This means Laravel instantiates the Action automatically without you having to manage its creation manually:
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Actions\ProcessCoursePayment;
use Illuminate\Http\Request;
class PaymentController extends Controller
{
/**
* Registers a payment by injecting the corresponding Action.
*/
public function store(Request $request, ProcessCoursePayment $processPayment)
{
$request->validate([
'amount' => 'required|numeric',
'course_id' => 'required|integer',
]);
// Execution of the use case passing sanitized data
$payment = $processPayment->handle($request->all(), $request->user());
return response()->json([ // or Resources Class
'success' => true,
'data' => $payment
], 201);
}
}The Structure of the Transactional Action
Inside the Action, logic is typically executed within a database transaction using DB::transaction(). This guarantees system integrity against any failure in the operation sequence: if a step fails, all changes made up to that point are automatically reverted (rollback), preventing inconsistent database states.
namespace App\Actions;
use App\Models\User;
use App\Models\Payment;
use Illuminate\Support\Facades\DB;
use App\Events\PaymentProcessedSuccessfully;
class ProcessCoursePayment
{
/**
* Executes the logical processing of the payment.
*/
public function handle(array $data, ?User $user): Payment
{
// A transaction is used to ensure all steps are consolidated
return DB::transaction(function () use ($data, $user) {
// 1. Persistence of the main entity
$payment = Payment::create([
'user_id' => $user?->id,
'amount' => $data['amount'],
'course_id' => $data['course_id'],
'status' => 'completed',
]);
// 2. Triggering system events (handling notifications, emails, etc.)
event(new PaymentProcessedSuccessfully($payment));
return $payment;
});
}
}Delimitation of Responsibilities: Logic purely tied to the ORM—such as local relationships, scopes, or mutators—should remain within the Eloquent model. In contrast, transactional operations involving multiple models, integration event management, or email sending should be fully delegated to the Action class.
Action Pattern to Unburden Controllers
Action classes are typically located in the App/Actions folder, following the hierarchy and organizational structure you prefer to avoid cluttering the project root with scattered files. It is a best practice to group them by domain: App/Actions/Payment, App/Actions/Rating, etc.
The main idea behind the Action pattern is to lift the workload off controllers. In the same way we create services for models that become heavy, the same applies to controllers: when they contain too much logic, we can encapsulate those operations into an independent Action class. The result is a much lighter controller with modular and easy-to-test code; the only trade-off is a slightly higher number of files in the project, which is a widely justified cost.
For example, in a payment processing flow—which is usually laborious due to the number of chained steps it requires—moving that logic to an Action prevents bloating the controller and makes the process completely reusable. That same Action class can be invoked from a REST API, an internal Artisan command, or directly from the admin panel to process manual payments without duplicating a single line of code.
Organization and Decoupling with Concerns (Traits)
We can take this level of modularization a step further using another widespread concept in the Laravel ecosystem: Concerns. In PHP, a Concern is simply a Trait—the mechanism that allows simulating aspects of multiple inheritance—which we group under the App/Concerns folder.
The purpose of a Concern is to add a specific responsibility or behavior to a class without letting it grow out of control. Each Concern extracts concrete functionality to avoid code duplication and keep classes focused on a single task. You can think of them as "plugins" added to the classes that need them via the use directive.
A scenario where a Concern is especially useful is the isolation of validation rules within an Action. Taking the creation of a ratings CRUD as an example:
app/Concerns/RatingValidationRules.php
<?php
namespace App\Concerns;
use App\Models\Book;
use App\Models\Tutorial;
use Illuminate\Validation\Rule;
trait RatingValidationRules
{
/**
* Get the validation rules for creating a rating.
*
* @return array<string, array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>>
*/
protected function createRatingRules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'description' => ['required', 'string', 'min:30'],
'rating' => ['required', 'integer', 'min:1', 'max:5'],
'rateable_type' => ['required', 'string', Rule::in([Book::class, Tutorial::class])],
'rateable_id' => ['required', 'integer'],
];
}
/**
* Get the validation rules for updating a rating.
*
* @return array<string, array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>>
*/
protected function updateRatingRules(): array
{
return [
'title' => ['sometimes', 'required', 'string', 'max:255'],
'description' => ['sometimes', 'required', 'string', 'min:30'],
'rating' => ['sometimes', 'required', 'integer', 'min:1', 'max:5'],
];
}
/**
* Get the validation rules for replying to a rating.
*
* @return array<string, array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>>
*/
protected function replyRatingRules(): array
{
return [
'reply_id' => ['required', 'integer', Rule::exists('ratings', 'id')],
'description' => ['required', 'string'],
];
}
}The Action needs to validate inputs before processing them. Although we can normally resort to Form Request classes, these present a clear limitation in decoupled architectures: they are tied to the HTTP lifecycle and cannot be used outside of it.
Below is an example of how to consume the Concern from an Action:
app/Actions/Rating/CreateRating.php
class CreateRating
{
use RatingValidationRules;
/**
* Validate and create a new rating.
*
* @param array<string, mixed> $input
*/
public function create(User $user, array $input): Rating
{
Validator::make($input, $this->createRatingRules())->validate();
$rateable = $this->resolveRateable(
(string) $input['rateable_type'],
(int) $input['rateable_id'],
);
if ($rateable->ratings()->where('user_id', $user->id)->exists()) {
throw ValidationException::withMessages([
'rateable_id' => [__('rating.already_rated')],
]);
}
***Why Not Use Form Requests Inside an Action?
The main problem with traditional Form Request classes is their coupling to the transport layer: they depend on the existence of an active HTTP request (Illuminate\Http\Request) in the application lifecycle. Without that request, they simply do not work.
An Action, by definition, must be agnostic to how it was invoked. It doesn't care if the information comes from a web controller, a command-line interface (CLI), a job queue (Queue), or an API endpoint. Since the Action does not directly receive the HTTP request, mocking a request just to leverage a Form Request would make no sense. Instead, we isolate manual validations using Validator::make() inside a dedicated Concern, which guarantees:
- Total decoupling from the HTTP protocol.
- Ease of performing unit tests and isolated integrations.
- High modularity and autonomous components that can be invoked from any layer of the application.
Key Difference Between an Action and a Concern
Since both concepts serve to isolate and reuse functionality, it's common to confuse them at first. However, their purpose and way of use differ quite clearly:
- Action: Represents a single, indivisible use case within the system (for example,
CreateRatingActionorProcessPaymentAction). It is an autonomous class that can be instantiated, invoked, and tested independently. - Concern: Is an abstraction—a Trait—that groups reusable methods, properties, or rules that complement another class. It cannot run independently, but is instead imported and injected into the classes that need it via
use(such as Actions themselves or Eloquent models).
In short: to build a modular system in Laravel, Actions execute the core business logic and Concerns provide the specific, reusable behaviors that support that execution. Used together, they allow maintaining a clean, predictable, and easy-to-scale architecture as the project grows.
Modularization in Laravel: Actions, Concerns, and Helpers in Endpoints
When optimizing a REST API or the application in general, endpoint unification is only the first layer of the work. While consolidating multiple repeated requests into a single trip optimizes network and HTTP performance (you can consult the REST API section for more details), at the backend level the need arises to organize and decouple the code processing that information.
Modular Architecture: Decoupling Tools
To avoid bloated controllers (fat controllers) or duplicated code between different layers of the framework, four main modularization strategies can be combined:
- Actions: Encapsulate a specific use case or business logic. They are ideal for complex operations like processing a payment, recording a transaction, or managing advanced CRUD operations. An Action typically exposes a main execution method (for example,
handle()). - Concerns (Traits): Add a reusable cross-cutting behavior or responsibility between classes. A Concern does not implement business logic on its own, but rather provides supporting functionalities, such as custom validations or data formatting.
- Helpers (Helper Functions): Global functions designed for recurring operations that need to be invoked from multiple layers (such as controllers, interface components, or Blade views).
- Refactored Endpoints: API wrappers that consume internal layers (Actions/Helpers) and return responses formatted in JSON.
Case Study: Logic Reuse Between API and Views
Let's consider retrieving the shopping cart. The logic requires validating the authenticated user, filtering already purchased products, and calculating the order total. Instead of duplicating this process in the API controller and the view component, the query is encapsulated within a Helper:
// Helper definition
function get_shopping_cart($user) {
// Logic to retrieve items, quantity, and total price
return [
'items' => $items,
'count' => $count,
'total' => $total
];
}This function can be consumed directly from any layer of the application:
- From the API: Invoking the function from the unified controller and wrapping the result in a JSON response object (
response()->json(...)). - From the presentation layer (Blade / Inertia / Livewire): Calling the same function during the initial layout load to inject data into the global window object or client state.
If the internal operation scales in complexity—for example, if querying notifications requires filtering by categories or interacting with external services—the Helper function can rely internally on a specific Action that manages that process, always maintaining the single responsibility principle.
There is no single universal tool to structure applications in Laravel. The balanced combination of Actions for heavy business logic, Concerns for shared behaviors, and Helpers for recurring global queries allows building a completely modular, maintainable, and scalable system over the long term.
Services in Laravel: What They Are, When to Use Them, and When to Avoid Them
In Laravel development, encapsulating business logic is essential to keep applications clean, maintainable, and scalable. Unlike controllers or models, a service (or service class) is a stateless class dedicated exclusively to processing business rules, knowing nothing about the outside world invoking it.
1. What is a Service in Laravel
A service is a PHP class that knows nothing about HTTP requests, routes, or template systems. Its main role is to act as the processing core, isolating complex logic far away from HTTP transport.
- They are typically located in the
app/Services/directory. - They are resolved through Laravel's service container via dependency injection.
- They lack internal state between independent requests.
- There is no native
php artisan make:servicecommand, as they are treated as pure classes created viaphp artisan make:class.
A service in Laravel is a stateless class that encapsulates business logic and is injected wherever needed. It allows your controllers to limit themselves to requesting and returning, keeps your logic testable without passing through HTTP, and lets the exact same code serve from the web, the console, and queues. But not every controller deserves a service: creating them without a clear motive produces empty layers that only complicate the code.
2. Why They Are Important
Delegating responsibilities to services brings direct architectural benefits:
- Lightweight Controllers: Controllers are strictly limited to receiving the request, delegating to the service, and returning a response.
- Input Channel Independence: The exact same business rule encapsulated in a service can be invoked identically from the web, an Artisan command, a job queue, or a unit test.
- Ease of Testing: They allow verifying business logic behavior in milliseconds, without needing to spin up databases or simulate HTTP requests.
- Framework Isolation: The application domain remains decoupled from Laravel-specific tools, facilitating future migrations and refactorings.
// Before: logic lives in the controller
public function store(StoreOrderRequest $request): JsonResponse
{
$order = Order::create($request->validated());
if ($order->total() > 1000 && ! $request->user()->hasDiscount()) {
$order->update(['status' => 'manual_review']);
}
foreach ($order->items as $item) {
Inventory::where('sku', $item->sku)->decrement('stock', $item->quantity);
}
return response()->json($order, 201);
}That method mixes validation, business rules, and side effects. Testing solely the > 1000 rule requires setting up a full HTTP request and a database with preconfigured inventory.
// After: controller coordinates, service decides
public function store(StoreOrderRequest $request, OrderService $orders): JsonResponse
{
$order = $orders->place(
items: $request->validated('items'),
customer: $request->user(),
);
return response()->json($order, 201);
}Now testing the rule is as simple as directly calling a method. No HTTP, no request, no magic.
3. Anatomy and Difference from Other Pieces (like Actions)
While an Action is oriented towards a single point-of-use responsibility (a specific use case with few methods), a service groups related business operations sharing a common context. A well-written service uses strict types, read-only properties (readonly), and interface-based contracts to decouple external implementations.
<?php
namespace App\Services;
use App\Contracts\InventoryGateway;
use App\Exceptions\InsufficientStock;
class OrderService
{
public function __construct(
private readonly InventoryGateway $inventory,
) {
}
/**
* Places an order and deducts inventory.
*
* @param array<int, array{sku: string, quantity: int}> $items
*/
public function place(array $items, Customer $customer): Order
{
$this->inventory->reserve($items);
try {
return Order::create([
'customer_id' => $customer->id,
'items' => $items,
]);
} catch (Throwable $exception) {
$this->inventory->release($items);
throw $exception;
}
}
}Four things to highlight:
- Property promotion with
readonly. The constructor is a real constructor and the object is immutable from the outside. - Explicit types everywhere. The signature is the documentation.
- Dependencies by contract, not by implementation.
InventoryGatewayis an interface: the test injects a double, production injects the real implementation. - No
request(),response(), orsession(). Nothing appearing here depends on there being an ongoing HTTP request.
Contracts (interfaces) are created with php artisan make:interface:
$ php artisan make:interface Contracts/InventoryGateway4. The Service Container and Binding
Laravel's container manages dependency resolution. When a service depends on contracts or interfaces, it is necessary to register the corresponding binding in service providers (like AppServiceProvider):
bind(): Generates a new instance every time it is requested. This is the default option suitable for stateless services.singleton(): Maintains a single shared instance across the entire application; useful for HTTP clients or expensive objects to initialize. It should be avoided if the class stores mutable state, to prevent data leaks between requests.
5. When TO Use a Service
It is advisable to implement a service when any of the following conditions are met:
- Business logic needs to be reused across multiple input channels (web, API, console, queues).
- The operational flow involves complex chained conditions or multiple sequential steps.
- It is necessary to isolate logic to subject it to fast unit tests without external dependencies.
- The operation handles infrastructure integrations with multiple possible implementations (for example, payment gateways or cloud storage providers).
6. When NOT to Use a Service (Over-engineering)
Avoid creating unnecessary services in the following scenarios:
- Disguised CRUDs: Classes that only replicate native Eloquent methods without contributing business rules (for example, methods that only call
Model::create()). // Not a service. It's an Eloquent method with more steps. class UserService { public function find(int $id): User { return User::findOrFail($id); } public function create(array $data): User { return User::create($data); } }- Anemic Services: Generic classes with names like
DataServiceorHelperServicethat do not represent a clear business intention. - Trivial Operations: Simple one- or two-line logic that can be solved directly in the controller or via the framework's native methods.
- A loose conditional:
// An if does not need its own class if ($order->total() > 0) { $order->update(['status' => 'paid']); }- The wrapper chain:
// This service only calls another service. Delete it. class CreateOrderService { public function __construct(private PlaceOrderService $place) {} public function execute(array $data): Order { return $this->place->handle($data); } }
7. Common Implementation Errors
- Including HTTP in the service: Manipulating
response()orrequest()objects inside the service class ruins its versatility; it is preferable to throw domain exceptions and let the controller translate them into appropriate HTTP responses. - Injecting Eloquent models through the constructor: It can leave stale instances loaded in memory; it is safer to pass identifiers or models as direct method arguments.
- Swallowing exceptions: Catching errors with empty
try/catchblocks or ones that return null values hides critical flow failures, making bugs much harder to track down. - Confusing services with helpers: A helper is a pure function without state or business identity, whereas a service models complex processes with well-defined dependencies and contracts.
8. Decision Checklist
Before creating a new service, evaluate the following questions:
- Will the logic be used in more than one application channel?
- Can it be tested in isolation without requiring an HTTP request?
- Does the class name describe a specific business intent rather than being a generic container?
- Can its main responsibility be explained in a single sentence?
If the overall answer is affirmative, using a service is fully justified. Otherwise, it is likely an unnecessary abstraction layer that only adds noise to the project.
9. Complete Example with Interface and Test
The contract:
<?php
namespace App\Contracts;
interface InventoryGateway
{
public function reserve(array $items): void;
public function release(array $items): void;
}The actual implementation:
<?php
namespace App\Services;
use App\Contracts\InventoryGateway;
use Illuminate\Support\Facades\Http;
class HttpInventoryGateway implements InventoryGateway
{
public function reserve(array $items): void
{
$response = Http::post(config('services.warehouse.url').'/reservations', ['items' => $items]);
$response->throw();
}
public function release(array $items): void
{
Http::post(config('services.warehouse.url').'/reservations/release', ['items' => $items])->throw();
}
}The business service:
<?php
namespace App\Services;
use App\Contracts\InventoryGateway;
use App\Exceptions\InsufficientStock;
use App\Models\Customer;
use App\Models\Order;
class OrderService
{
public function __construct(private readonly InventoryGateway $inventory)
{
}
/**
* @param array<int, array{sku: string, quantity: int}> $items
*
* @throws InsufficientStock
*/
public function place(array $items, Customer $customer): Order
{
try {
$this->inventory->reserve($items);
} catch (Throwable $exception) {
throw new InsufficientStock(previous: $exception);
}
return Order::create([
'customer_id' => $customer->id,
'items' => $items,
'total' => $this->calculateTotal($items),
]);
}
/**
* @param array<int, array{sku: string, quantity: int, price: int}> $items
*/
public function calculateTotal(array $items): int
{
return array_reduce(
$items,
fn (int $carry, array $item): int => $carry + ($item['price'] * $item['quantity']),
0,
);
}
}The binding in AppServiceProvider:
public function register(): void
{
$this->app->singleton(InventoryGateway::class, HttpInventoryGateway::class);
}The test:
<?php
use App\Exceptions\InsufficientStock;
use App\Models\Customer;
use App\Services\OrderService;
it('creates an order and reserves inventory', function () {
$gateway = Mockery::mock(InventoryGateway::class);
$gateway->shouldReceive('reserve')->once()->with([
['sku' => 'ABC-1', 'quantity' => 2, 'price' => 500],
]);
$order = (new OrderService($gateway))->place(
items: [['sku' => 'ABC-1', 'quantity' => 2, 'price' => 500]],
customer: Customer::factory()->create(),
);
expect($order->total)->toBe(1000);
});
it('translates an inventory failure into a domain exception', function () {
$gateway = Mockery::mock(InventoryGateway::class);
$gateway->shouldReceive('reserve')->andThrow(new RuntimeException('timeout'));
expect(fn () => (new OrderService($gateway))->place([], Customer::factory()->create()))
->toThrow(InsufficientStock::class);
});None of those tests touch HTTP, routes, or the service container. That is why they run fast and why they fail usefully when something breaks.