Where business logic belongs — service classes and the service container
The previous chapter finished validating input. This chapter writes what comes after it.
Written straightforwardly, the controller swells. It still works while swollen, but tests get harder to write, and you are stuck the moment you want to call the same processing from another entry point (an Artisan command, or a queue). This chapter builds the criteria for what goes where.
First, write it straightforwardly
Taking the StoreOrderRequest from chapter 5, we create the order.
public function store(StoreOrderRequest $request)
{
$validated = $request->validated();
$total = 0;
foreach ($validated['items'] as $item) {
$stock = Stock::where('product_id', $item['product_id'])->first();
if ($stock->quantity < $item['quantity']) {
return response()->json(['message' => 'Not enough stock'], 409);
}
$product = Product::find($item['product_id']);
$total += $product->price * $item['quantity'];
}
$order = Order::create([
'customer_email' => $validated['customer_email'],
'status' => OrderStatus::Pending,
'total_amount' => $total,
]);
foreach ($validated['items'] as $item) {
$product = Product::find($item['product_id']);
$order->items()->create([
'product_id' => $item['product_id'],
'quantity' => $item['quantity'],
'unit_price' => $product->price,
]);
Stock::where('product_id', $item['product_id'])
->decrement('quantity', $item['quantity']);
}
Mail::raw('Thank you for your order', function ($message) use ($validated) {
$message->to($validated['customer_email'])->subject('Your order has been received');
});
return response()->json($order, 201);
}
It works. It has the same structure as the code shown in chapter 1.
Counting what is in there
Here is everything this method handles.
- Checking there is enough stock
- Calculating the total
- Creating the order record
- Creating the order lines
- Decrementing stock
- Sending the confirmation email
- Assembling the HTTP response
That is seven. Counting like this is the starting point for extraction.
It is not "the controller is long, so shorten it." Length is a result, not a cause. The cause is that processes with different reasons to change are living together in one method.
- Change the email copy → only 6 changes
- Change how stock is held → 1 and 5 change
- Add a field to the response → only 7 changes
While they live together, fixing the email copy means opening the order creation code. Having opened it you have to read it, and reading it creates the risk of touching it by mistake.
"Can the reasons to change be narrowed to one?" is a restatement of the single responsibility principle. The principle itself is covered by the PHP Class Design Guide. This series concentrates on which Laravel file the principle lands in.
What goes in an Eloquent model, and what does not
Before thinking about where to extract to, settle the model's role. Leave it vague and the processing evicted from the controller piles up in the model instead, producing a Fat Model.
What a model holds is the representation and interpretation of its own data.
class Order extends Model
{
/**
* Whether the amount computed from the lines matches the stored total
*/
public function isAmountConsistent(): bool
{
return $this->items->sum(fn ($item) => $item->unit_price * $item->quantity)
=== $this->total_amount;
}
/**
* Whether it is in a cancellable state
*/
public function isCancellable(): bool
{
return $this->status === OrderStatus::Pending;
}
}
Both can be decided from the data the Order holds. Neither looks at another table nor talks to anything external.
class Order extends Model
{
public function place(array $items): void
{
// Look at the stock table, decrement it, send mail
}
}
This is not about Order alone; it is about coordinating Order, Stock, and sending mail. The coordinator goes outside the model.
When the call is hard, ask "does this method touch other models or services?" If it does, it is not the model's job.
Extracting into a service class
Create the coordinator class.
<?php
namespace App\Services;
use App\Enums\OrderStatus;
use App\Models\Order;
use App\Models\Product;
class OrderPlacementService
{
public function __construct(
private StockChecker $stockChecker,
) {}
/**
* @param array<int, array{product_id: int, quantity: int}> $items
*/
public function place(string $customerEmail, array $items): Order
{
$this->stockChecker->assertEnough($items);
$products = Product::findMany(array_column($items, 'product_id'))->keyBy('id');
$total = 0;
foreach ($items as $item) {
$total += $products[$item['product_id']]->price * $item['quantity'];
}
$order = Order::create([
'customer_email' => $customerEmail,
'status' => OrderStatus::Pending,
'total_amount' => $total,
]);
foreach ($items as $item) {
$order->items()->create([
'product_id' => $item['product_id'],
'quantity' => $item['quantity'],
'unit_price' => $products[$item['product_id']]->price,
]);
}
$this->stockChecker->decrease($items);
return $order;
}
}
The controller becomes this.
public function store(StoreOrderRequest $request, OrderPlacementService $service)
{
$validated = $request->validated();
$order = $service->place($validated['customer_email'], $validated['items']);
return response()->json($order, 201);
}
What remains in the controller is only the HTTP entrance and exit. Pull values out of the request, call the processing, assemble the response. Those three are HTTP-specific work, and that is the controller's real role.
Do not let it be just a file split
The classic way extraction fails is copying the processing into another file unchanged.
class OrderService
{
public function place(...) {}
public function cancel(...) {}
public function ship(...) {}
public function refund(...) {}
public function exportCsv(...) {}
public function sendReminder(...) {}
}
The controller got shorter, but the problem merely moved. Fixing the CSV export means opening the order processing file.
The rule of thumb is whether the class name explains what the processing is. OrderPlacementService names one thing: confirming an order. OrderService means "something to do with orders," and anything fits.
For the example above, split it into OrderPlacementService, OrderCancellationService, and SalesReportExporter.
Checking and decrementing stock go in their own class
StockChecker is separate because stock handling is used from places other than ordering: adjustments from the admin screen, returns putting stock back, periodic stocktakes. All of them need the same logic.
<?php
namespace App\Services;
use App\Exceptions\InsufficientStockException;
use App\Models\Stock;
class StockChecker
{
/**
* @param array<int, array{product_id: int, quantity: int}> $items
*
* @throws InsufficientStockException
*/
public function assertEnough(array $items): void
{
$stocks = Stock::whereIn('product_id', array_column($items, 'product_id'))
->get()
->keyBy('product_id');
foreach ($items as $item) {
$available = $stocks->get($item['product_id'])?->quantity ?? 0;
if ($available < $item['quantity']) {
throw new InsufficientStockException(
productId: $item['product_id'],
requested: $item['quantity'],
available: $available,
);
}
}
}
/**
* @param array<int, array{product_id: int, quantity: int}> $items
*/
public function decrease(array $items): void
{
foreach ($items as $item) {
Stock::where('product_id', $item['product_id'])
->decrement('quantity', $item['quantity']);
}
}
}
InsufficientStockException is created in chapter 9. For now just take in the shape: throw when stock is insufficient.
Two things about how this is written are deliberate.
whereIn gathers it into one query. Calling Stock::where(...)->first() inside the loop fires one query per line. It is the same structure as the N+1 problem covered in chapter 8.
?->quantity ?? 0 handles the case where there is no stock row. ?-> is PHP 8's nullsafe operator: if the left side is null, it returns null without calling the method or reading the property. The chapter 3 schema does not enforce "every product has a stock row," so products with no stock row can exist. Write $stock->quantity without checking and ordering that product gives you a 500 on the spot. Treating a missing stock row as zero stock and returning 409 is the correct behavior.
Between checking in assertEnough and decrementing in decrease, another request may take the same stock. The problem raised in chapter 5 is still here. Chapter 14 closes it with transactions and row locks.
The service container assembles the dependencies
Did you notice that the controller above never calls new on OrderPlacementService?
public function store(StoreOrderRequest $request, OrderPlacementService $service)
All we did was write a type on the argument. An instance arrives in $service regardless.
That is the service container from chapter 2 at work. Before calling a controller method, Laravel looks at the argument types and assembles the classes it needs. If the OrderPlacementService constructor asks for a StockChecker, it builds and passes that too.
The official documentation calls this zero configuration resolution. A class with no dependencies, or one that depends only on concrete classes, can be resolved without writing any configuration at all1. It works the same way in event listeners, middleware, and queued jobs, not only controllers.
What you gain
Compare it with writing new.
public function store(StoreOrderRequest $request)
{
$service = new OrderPlacementService(new StockChecker());
// ...
}
This code has two problems.
Adding a dependency breaks the caller. If OrderPlacementService also comes to need a logger, you have to fix every place that calls new. Leave it to the container and adding a constructor argument is enough.
You cannot swap things out in tests. Chapter 10 writes tests, and there you cannot "replace only the stock check with a fake and exercise only the order creation logic," because the new is buried inside the method.
Passing an interface requires a binding
Automatic resolution works when the type is a concrete class. Type-declare an interface and Laravel has no idea which implementation to hand over.
interface StockCheckerInterface
{
public function assertEnough(array $items): void;
public function decrease(array $items): void;
}
class OrderPlacementService
{
public function __construct(
private StockCheckerInterface $stockChecker, // which one should it pass?
) {}
}
bind is what teaches it the correspondence. As chapter 2 showed, it goes in a service provider's register method.
use App\Services\StockCheckerInterface;
use App\Services\EloquentStockChecker;
public function register(): void
{
$this->app->bind(StockCheckerInterface::class, EloquentStockChecker::class);
}
Now anywhere that type-declares StockCheckerInterface receives an EloquentStockChecker1.
The official documentation narrows the situations where you need to operate the container by hand to two: binding an implementation to an interface, and writing a Laravel package1. Everything else can be left to automatic resolution.
Whether to create an interface
Putting an interface in the middle lets you swap the implementation. If stock starts coming from an external inventory system, you write ApiStockChecker and change one bind line.
On the other hand, if there is only one implementation and no plan to swap it, the interface just adds registration work. Type-declare the concrete class and no bind is needed.
In this series, classes that talk to external services (payment, sending mail) get an interface, and classes that stay entirely inside the application do not. The former definitely need swapping in tests.
Sharing an instance with singleton
A class registered with bind is created afresh every time it is asked for. Ask from three places in one request and you get three of them.
Something expensive to create (opening a connection, reading a config file) is shared as one.
public function register(): void
{
$this->app->singleton(PaymentGatewayClient::class);
}
AppServiceProvider also has a property form2.
public $bindings = [
StockCheckerInterface::class => EloquentStockChecker::class,
];
public $singletons = [
PaymentGatewayClient::class => StripeClient::class,
];
When the registration is nothing more than a simple mapping, this form reads better.
What this chapter does not cover
This series tidies things up within Laravel's standard structure. It does not go into the following.
- The use case layer (application services) — splitting service classes further into "one class, one operation" and passing input via command objects. Designing the use case layer covers it
- The repository pattern — hiding the dependency on Eloquent behind an interface to separate the domain model from persistence. The repository pattern covers it
- Contextual binding — handing a different implementation of the same interface to each injection site. Dependency injection and the Laravel container covers how
when()->needs()->give()is written and when it earns its place1
All three become necessary once an application has grown. The time to consider them is when the split in this chapter starts feeling cramped.
What matters in production
Do not give a singleton request-specific state
On a normal web request, process state is thrown away when processing finishes. But a queue worker is one process that keeps running. Chapter 16 covers this: a worker does not exit after handling one job, it goes and fetches the next.
If a class registered as a singleton still holds information from the previous job, the next job reads it.
class OrderContext
{
private ?Order $currentOrder = null;
public function setCurrentOrder(Order $order): void
{
$this->currentOrder = $order;
}
}
Register this class as a singleton and the order the worker processed previously is visible to the next job. Another customer's order gets mixed in, so the damage is severe.
The only things fit to be a singleton are ones that are stateless, or that hold state whose meaning does not change across requests (configuration values, connections).
Calling a facade directly makes swapping in tests harder
Writing things like Mail::raw() or Cache::get() uses a facade. It is short, but used inside a service class it means the constructor no longer tells you what the class depends on.
class OrderPlacementService
{
public function __construct(
private StockChecker $stockChecker,
) {}
public function place(...): Order
{
// ...
Mail::raw(...); // a dependency that does not appear in the constructor
}
}
Laravel's facades have a mechanism for swapping in tests, so it does not make tests impossible. Chapter 15 uses Notification::fake() and Mail::fake(). Even so, the constructor arguments being the list of that class's dependencies is kinder to readers.
Note that in this chapter's code, sending mail is not inside place() yet either. Chapter 15 extracts it as an event.
Check cohesion after extracting
Extraction is not a one-off. Every feature you add gives that class one more thing to handle.
If OrderPlacementService is 500 lines six months from now, count again. Several "reasons to change" mean it is time to split it again.
Summary
- The criterion for extraction is not length but whether there are several reasons to change
- What goes in a model stops at the representation and interpretation of its own data. Processing that touches other models or services goes outside
- What stays in the controller is only the HTTP entrance and exit
- Writing a type on the argument is enough for the service container to assemble the dependency. Concrete classes need no configuration
bindis needed only when you type-declare an interface- A
singletonsurvives across requests. Do not give it in-flight data
What to read next
The next chapter, Designing the output — deciding the shape of a response with an API Resource, designs the shape of the response we currently return with response()->json($order, 201). We stop returning the model as is and pin down in code what gets exposed. The chapter 1 problem of "internal information leaking the moment a column is added" is closed there.
Exercises
The following service class is a failed extraction. Explain what is wrong and how you would split it
class OrderService
{
public function place(string $email, array $items): Order { /* ... */ }
public function cancel(Order $order): void { /* ... */ }
public function generateMonthlyReport(int $year, int $month): string { /* ... */ }
public function syncWithWarehouse(): void { /* ... */ }
}
Sample answer
The four methods all change for different reasons.
place/cancel— when the business rules for orders changegenerateMonthlyReport— when the fields accounting wants changesyncWithWarehouse— when the warehouse system's API changes
Responding to a change in the warehouse system's spec means opening the file that contains the order confirmation code. Opening it means reading it, and reading it creates the risk of touching it.
The cause is a class name that means "something to do with orders." The name OrderService accepts any processing at all.
Here is one way to split it.
OrderPlacementService— confirming an orderOrderCancellationService— cancelling an orderMonthlySalesReporter— generating the monthly reportWarehouseSynchronizer— syncing with the warehouse system
Whether to keep place and cancel in the same class or split them is a judgment call. If they share the same data and the same rules, living together can still count as one reason to change. When deciding the cancellable window is entangled with the order confirmation logic, keeping them together can read better.
The following code works correctly for web requests, but mixes up customers' data when called from a queue worker. Explain the cause
// app/Providers/AppServiceProvider.php
$this->app->singleton(InvoiceNumberGenerator::class);
// app/Services/InvoiceNumberGenerator.php
class InvoiceNumberGenerator
{
private int $sequence = 0;
private ?string $customerCode = null;
public function forCustomer(string $code): self
{
$this->customerCode = $code;
return $this;
}
public function next(): string
{
$this->sequence++;
return sprintf('%s-%04d', $this->customerCode, $this->sequence);
}
}
Sample answer
Because it is registered with singleton, the container creates one instance and keeps returning it.
On a web request the process ends with the request, so the problem never surfaces. When the request finishes, the PHP process and its state are thrown away, and the next request starts from a new instance.
A queue worker is different. It stays up and processes job after job. Chapter 16 covers this; it is intended behavior, and it is faster precisely because the framework is not booted each time.
The result is this.
Job 1 (customer A): forCustomer('A') → next() → "A-0001"
Job 2 (customer B): forget to call forCustomer('B') before next() → "A-0002"
Job 3 (customer C): forCustomer('C') → next() → "C-0003" ← the sequence carried over
$customerCode keeps the previous job's value, and $sequence keeps climbing across jobs. Both are wrong for an invoice number.
The cause is how singleton is used. This class holds state that changes per operation, so it must not be shared. Use bind (create a new one on every request for it), or design it not to hold state at all.
public function generate(string $customerCode, int $sequence): string
{
return sprintf('%s-%04d', $customerCode, $sequence);
}
Take the values as arguments and return on the spot, and sharing causes no problem.