Laravel Service Repository Pattern

date: Aug 30, 2026

Three names for the same delivery. The dispatcher sees an internal id. The customer has a tracking number off a confirmation email. The client's own ERP has an order number it invented years ago and will not be changing.

One row, three ways in, none of them going away.

I spent a stretch on a delivery management system, a couple hundred drops a day, dispatchers on a web panel and drivers on a phone. Enterprise systems accumulate identities like that, and the only real question is which file absorbs the mess. Put it in the controller and every controller learns it. Put it in the model and it grows a method per caller.

Service repository puts it in one place on purpose. Three layers, presentation over business logic over data access, and the middle one is Fowler's Service Layer. Note it counts to three. The model is not a layer, it is what travels through them, which is a claim I am going to walk back before this section ends.

The code below is Laravel-shaped, and illustrative rather than lifted from that system.

Three names for one row

A customer opens the tracking page. The controller does one thing:

class TrackingController extends FrontBaseController
{
    public function show(string $trackingNumber): View|RedirectResponse
    {
        $delivery = $this->deliveryService->getDelivery(trackingNumber: $trackingNumber);
 
        if (! $delivery) {
            return redirect()->route($this->trackingRoutePath::INDEX)
                ->withErrors($this->deliveryMessage->notFound());
        }
 
        return view($this->trackingRoutePath::SHOW, [
            'delivery' => $delivery,
        ]);
    }
}

That is the entire data access in the tracking controller. It has no idea whether resolving a tracking number is one query or six.

The service answers a different question:

class DeliveryService extends BaseService
{
    public function getDelivery(
        ?int $deliveryId = null,
        ?string $trackingNumber = null,
        ?string $orderNumber = null,
    ): ?Delivery {
        if ($trackingNumber) {
            return $this->deliveryRepository->getByTrackingNumber($trackingNumber);
        }
 
        if ($orderNumber) {
            return $this->deliveryRepository->getByOrderNumber($orderNumber);
        }
 
        return $this->deliveryRepository->getById($deliveryId);
    }
}

No SQL. Just a decision about which identity you meant, made once, in the one file that knows all three exist.

The repository is the first layer that touches the database:

class DeliveryRepository extends BaseRepository
{
    public function getByTrackingNumber(string $trackingNumber): ?Delivery
    {
        return $this->delivery
            ->where('tracking_number', $trackingNumber)
            ->trackable()
            ->with(['driver', 'route', 'statusHistory'])
            ->first();
    }
}

One place knows a delivery is addressed by its tracking_number column, and one place knows a tracking page needs the driver, route and status history loaded with it. Both change here and nowhere else.

Then the model, and the part I said I would walk back:

class Delivery extends Model
{
    public function scopeTrackable(Builder $query): void
    {
        $query->whereIn('status', DeliveryStatus::trackable())
            ->where('dispatched_at', '<=', now());
    }
}

trackable is not a column. It is a rule the operations team owns: dispatched, not cancelled, still inside the window where a customer is allowed to see it. The repository calls trackable() and never defines it, so when ops decides returns stay visible another 30 days, one scope changes and every query inherits the new meaning.

Which means the model is holding business logic while I just finished saying it is not a layer. Both are true and I would still do it this way. The purist move is a query object, or the same whereIn copied into each repository method that needs it. The scope costs you a straight answer to "where does the business logic live", and buys you one definition instead of six.

Four questions, no overlap. What do I render, which identity did you mean, what query answers that, what does trackable mean.

The cost is a jump-to-definition chain. Answering "what does this page query" means opening four files in sequence, and the answer is never on one screen. Cheap while the query is stable. Less cheap at 11pm chasing an N+1.

Every layer subtracts

The write path earns more than the read.

A dispatcher assigns a delivery to a driver. The controller hands over a payload and leaves:

class DeliveryDispatchController extends AdminBaseController
{
    public function store(Delivery $delivery, DeliveryDispatchRequest $request): RedirectResponse
    {
        $dispatched = $this->deliveryService->dispatchDelivery(
            $delivery,
            $request->getAttributes(),
        );
 
        throw_if(! $dispatched, RedirectResponseException::class, $this->deliveryMessage->dispatchFailed());
 
        return redirect()->route($this->deliveryRoutePath::SHOW, $delivery)->with([
            'message' => $this->deliveryMessage->dispatchSuccess(),
            'status' => true,
        ]);
    }
}

It never reads an attribute or names a column.

The service adds the things only it knows:

class DeliveryService extends BaseService
{
    public function dispatchDelivery(Delivery $delivery, array $attributes): bool
    {
        $driver = $this->driverService->getAvailableDriver(
            driverId: $attributes['driver_id'],
            scheduledFor: $delivery->scheduled_for,
        );
 
        if (! $driver || $driver->vehicle->isGrounded()) {
            return false;
        }
 
        $dispatched = $this->deliveryRepository->dispatch($delivery, [
            ...$attributes,
            'driver_id' => $driver->id,
            'dispatched_by' => $this->getAuthUser()->id,
            'dispatched_at' => now(),
        ]);
 
        if ($dispatched) {
            $this->notificationService->deliveryDispatched($delivery, $driver);
        }
 
        return $dispatched;
    }
}

This is where the business actually lives. The controller does not know a vehicle can be grounded for service. The repository does not know a customer gets an SMS.

Two things in that method are worth flagging as they stand, not as lessons I caught later.

array $attributes crosses two boundaries with nothing enforcing its shape, and $attributes['driver_id'] is a runtime lookup into an array that a form request happens to fill today. Rename the field in DeliveryDispatchRequest and nothing breaks until a dispatcher clicks the button. A DTO fixes it and costs another class per use case, which is a real argument at three use cases and an obvious yes at thirty.

And DeliveryService reaches into DriverService. Services calling services is where the layering starts to sag, because nothing in the pattern stops DriverService from calling back and Laravel's container will happily resolve the cycle until it cannot.

Then the repository, where one dispatch becomes four writes:

class DeliveryRepository extends BaseRepository
{
    public function dispatch(Delivery $delivery, array $attributes): bool
    {
        return DB::transaction(function () use ($delivery, $attributes) {
            $stops = Arr::pull($attributes, 'route_stops', []);
 
            $saved = $delivery->fill([
                ...$attributes,
                'status' => DeliveryStatus::DISPATCHED,
            ])->save();
 
            $delivery->stops()->sync($stops);
 
            $delivery->statusHistory()->create([
                'status' => DeliveryStatus::DISPATCHED,
                'actor_id' => $attributes['dispatched_by'],
            ]);
 
            return $saved;
        });
    }
}

The row, the stops pivot, the status history entry, and a transaction around all of it so a half-dispatched delivery never exists. The service handed down a flat array and never learns that stops live in a pivot table.

Each layer adds or removes something, and nothing downstream trusts anything upstream. That is the strongest argument the pattern has.

When it starts paying

Four files to change one behaviour is a tax, and for a long stretch of a project's life it is a tax on nothing.

On a CRUD admin panel where the controller is Model::create($request->validated()) and always will be, this buys you three indirections and a repository whose every method wraps a single Eloquent call. That is a file you maintain for free.

It starts paying the moment a second entry point wants the same read. Once the driver app and an ERP webhook both need to resolve a delivery, getByTrackingNumber stops being a wrapper and becomes the definition. It pays again the first time a write touches more than one table, because the transaction has to live somewhere and a controller is the worst option on the list.

If neither of those is true yet, write the fat controller. It is easier to extract a service from a controller that works than to guess the seams up front.

Where this shape goes next

All of this is one arrangement of a much older idea, and the older idea is better documented than the arrangement. If these boundaries are doing something for you, these are the doors I would open next.

The catalog it came from. Fowler's enterprise patterns catalog is where Service Layer and Repository are actually specified, a page each. Read Transaction Script against Domain Model first, because that is the fork this post is standing on. A service running one procedure per use case is a transaction script, a legitimate choice rather than a lesser one, and knowing which you are writing settles most arguments about where a rule belongs. Unit of Work is worth the page too, since that DB::transaction call is a hand-rolled corner of it.

Pointing the dependencies inward. The layering here is vertical, which quietly makes the database the thing everything else is arranged around. Hexagonal architecture, also called ports and adapters, turns that inside out: the domain sits in the middle and the database becomes one more adapter, no more privileged than the HTTP layer. Clean architecture is the same instinct with different diagrams. Both are a bigger commitment than what this post describes.

Letting reads and writes diverge. The tracking page needs three relations eager loaded. The dispatch panel needs rules enforced and four writes in a transaction. Same table, opposite needs, one Delivery model serving both. CQRS is what happens when you stop making them share, and Fowler is refreshingly blunt about when that is overkill.

Announcing instead of calling. dispatchDelivery names a notification service, so the dispatch rule now knows SMS exists. A domain event inverts that: the service announces a delivery was dispatched and stops caring who listens. Laravel ships events in the box, which makes it the cheapest of these to try on a Tuesday.

by Sandev Abeykoon