Skip to main content

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.

app/Http/Controllers/OrderController.php
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.

  1. Checking there is enough stock
  2. Calculating the total
  3. Creating the order record
  4. Creating the order lines
  5. Decrementing stock
  6. Sending the confirmation email
  7. 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.

If you want to learn the criterion itself

"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.

✅ Good: how to present 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.

❌ Bad: putting processing that drives other things in the model
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.

app/Services/OrderPlacementService.php
<?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.

app/Http/Controllers/OrderController.php
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.

❌ Bad: the name amounts to 'miscellaneous'
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.

app/Services/StockChecker.php
<?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.

This implementation is not safe yet

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.

❌ Assembling with 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.

app/Providers/AppServiceProvider.php
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.

app/Providers/AppServiceProvider.php
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.

❌ Bad: accumulating in-flight data in a singleton
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.

The dependency is invisible
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
  • bind is needed only when you type-declare an interface
  • A singleton survives across requests. Do not give it in-flight data

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 change
  • generateMonthlyReport — when the fields accounting wants change
  • syncWithWarehouse — 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 order
  • OrderCancellationService — cancelling an order
  • MonthlySalesReporter — generating the monthly report
  • WarehouseSynchronizer — 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.


Footnotes​

  1. Source: Service Container (Laravel documentation 13.x). On zero configuration resolution, the two situations where you must operate the container by hand, binding implementations to interfaces, and contextual binding. ↩ ↩2 ↩3 ↩4

  2. Source: Service Providers (Laravel documentation 13.x). On registration via the $bindings and $singletons properties. ↩