You join a Laravel codebase that was written by someone who'd just left a Spring Boot job. Every controller calls a UserService. Every UserService method calls a UserRepository. Every UserRepository method wraps a single Eloquent query. You ask why the repository exists and the answer is "in case we change the database." You've never met anyone who has changed databases.

You join a different Laravel codebase. The controllers are 200-line monsters that mix Stripe webhook parsing, Eloquent updates, queue dispatches, and Slack notifications. You ask if there's a service layer and the answer is "we just put things where they go." Six developers' definition of "where they go" is six different folders.

These are the two failure modes of the service-layer conversation in Laravel. One team over-engineers it because Java did. The other team avoids it because Laravel's defaults said they could. Neither is asking the right question, which is: what problem are services actually solving, and does Laravel already solve it for me?

What Service Classes Are Supposed To Do

The case for a service layer comes from older enterprise architectures. In Spring or .NET, a "service" is the place where:

  1. Business logic lives, separated from the HTTP layer.
  2. Multiple domain models are coordinated inside a transaction.
  3. The use case is testable without booting the web framework.

That's a real need. The question Laravel forces you to ask is: which pieces of that need does the framework already cover, and which are still on you?

Laravel covers (1) better than people give it credit for — Form Requests handle input shape and authorization at the edge, Policies handle row-level permission, and Eloquent models can carry a few legitimate domain methods. It covers (2) with DB::transaction() and Events. It covers (3) with the testing facades and dependency injection.

What Laravel does not cover is a named home for a multi-step use case that touches three models, fires events, and has rules nobody can find. That's the gap. The question is whether you fill it with a "Service" or with something else.

The Test: Where Does The Use Case Currently Live?

Before you decide whether to introduce a service layer, look at where the logic lives today. There are usually three patterns and only one of them needs help.

Pattern A — fat controller. The use case is 80 lines inside UserController@store. Validation, password hashing, default workspace creation, welcome email dispatch, audit log entry. You want this code to leave the controller. A service class is one option; an Action is another. Both work.

Pattern B — fat model. The use case is 80 lines inside User::register(). The model now knows about workspaces, mail templates, and audit logs — three things that should not depend on the user model. You want this code to leave the model. Same answer: Action or service.

Pattern C — already in a Job. The use case is 80 lines inside RegisterUserJob@handle. It works, it's testable, and it's already async. You don't need a service layer; the job is the use case. Don't add a class to call from the job.

The mistake is reaching for a service layer in pattern C — wrapping a job's logic in a service so the job can call the service so the controller can also call the service. That's not architecture, that's misdirection. Just call the job from the controller (or dispatch it) and let the handler own the work.

Diagram comparing three Laravel patterns side by side. Left panel shows an 'Anaemic service layer' — controller calls UserService::create() which calls UserRepository::store() which calls User::create(), three layers of pass-through. Middle panel shows 'Action class' — controller invokes RegisterUser::execute() which owns the transaction and event dispatch directly. Right panel shows 'Service that earns rent' — a StripeBillingService coordinating Stripe API, Subscription model, Invoice model, and a queued listener inside a single transaction.
Three patterns: pass-through, Action, and a service that actually earns its abstraction

When A Service Class Earns Its Keep

There are cases where a class named BillingService or OnboardingService is the right call. They share a shape:

  1. The use case spans multiple domain models that don't naturally belong on any one model. "Charge a subscription, mark the invoice paid, schedule the renewal, notify the customer" touches Subscription, Invoice, Schedule, and Notification — none of those models should own the orchestration.
  2. It wraps an external system with its own concerns. A StripeBillingService that translates between your domain and Stripe's API, handles idempotency keys, and centralizes retry logic is doing real work.
  3. It's stateful in a way Actions aren't. A CartService that holds a per-request cart instance and exposes add(), remove(), total() is a service. An Action by definition takes inputs and returns outputs; if you find yourself wanting setX() and getX(), you want a service.

Here's the shape that holds up:

PHP
// app/Billing/Services/StripeBillingService.php
namespace App\Billing\Services;

use App\Billing\Models\Invoice;
use App\Billing\Models\Subscription;
use Illuminate\Support\Facades\DB;
use Stripe\StripeClient;

final class StripeBillingService
{
    public function __construct(private readonly StripeClient $stripe) {}

    public function chargeSubscription(Subscription $subscription): Invoice
    {
        return DB::transaction(function () use ($subscription) {
            $charge = $this->stripe->charges->create(
                [
                    'amount'   => $subscription->amount_cents,
                    'currency' => $subscription->currency,
                    'customer' => $subscription->stripe_customer_id,
                ],
                [
                    'idempotency_key' => "sub:{$subscription->id}:{$subscription->current_period_start}",
                ],
            );

            return Invoice::create([
                'subscription_id' => $subscription->id,
                'amount_cents'    => $charge->amount,
                'stripe_charge'   => $charge->id,
                'status'          => 'paid',
            ]);
        });
    }

    public function refundCharge(Invoice $invoice, int $cents): void
    {
        $this->stripe->refunds->create([
            'charge' => $invoice->stripe_charge,
            'amount' => $cents,
        ]);

        $invoice->update(['refunded_cents' => $invoice->refunded_cents + $cents]);
    }
}

The class has two methods that both wrap Stripe with domain meaning. They share a constructor (the StripeClient), they share a concern (idempotency, retries, the same exception types). Putting them on the same class isn't ceremony; it's cohesion.

When A Service Class Is Just A Slow Action

The pattern that doesn't earn its keep is the one most teams ship first:

PHP
// Don't do this
final class UserService
{
    public function __construct(private readonly UserRepository $repository) {}

    public function createUser(array $data): User
    {
        return $this->repository->store($data);
    }
}

final class UserRepository
{
    public function store(array $data): User
    {
        return User::create($data);
    }
}

That's three layers to call User::create(). Every layer has a constructor, a test file, and a blank space where logic would go if any was needed. None of it adds value over User::create($request->validated()) in the controller, or RegisterUser::execute($request->validated()) if you want a single-purpose Action.

A useful test: if removing a layer wouldn't change the behavior, the layer isn't doing anything yet. Delete it. You can always add it back when there's actual logic to host.

Actions Cover The Single-Use-Case Job

For the "this controller has 80 lines of business logic" problem, an Action class is usually a better fit than a Service. The shape is one verb per class:

PHP
// app/Actions/RegisterUser.php
final class RegisterUser
{
    public function __construct(
        private readonly Hasher $hasher,
        private readonly Dispatcher $bus,
    ) {}

    public function execute(array $payload): User
    {
        return DB::transaction(function () use ($payload) {
            $user = User::create([
                'email'    => $payload['email'],
                'password' => $this->hasher->make($payload['password']),
            ]);

            Workspace::createDefault($user);
            $this->bus->dispatch(new SendWelcomeEmail($user));

            return $user;
        });
    }
}

Why this beats a UserService::create()? Because it's named for the use case (RegisterUser, not User*), it has a single public method (you don't grep for which of seventeen methods does the thing), and it's trivially testable in isolation. The day you need an UpgradeUserToPro use case, you create UpgradeUserToPro, not a fifth method on UserService.

The pattern works at scale. A folder of app/Billing/Actions/ChargeSubscription.php, app/Billing/Actions/RefundInvoice.php, app/Billing/Actions/CancelSubscription.php is much easier to navigate than a 600-line BillingService with a dozen methods.

Five-card decision rule arranged around a central Use Case node. Card 01 One Verb Use An Action — RegisterUser::execute, single class single public method named for the use case. Card 02 Coordination Plus State Use A Service — StripeBillingService wraps Stripe, CartService holds long-lived per-request state, OnboardingService spans multiple domains. Card 03 Wrapper Adds Nothing Delete It — UserService delegating to UserRepository delegating to User::create is negative-value abstraction. Card 04 Read The Existing Code First — match the pattern the team already shipped, do not seed two conventions in one PR. Card 05 Repository Eloquent Already Is One — use UserQuery or InvoiceFinder for read-side helpers, anything ending in Repository accumulates write methods.
The decision rule for Service vs Action vs neither, on one screen

A Decision Rule That Holds Up

When the question comes up — Service or Action or neither — these three checks answer it ninety percent of the time.

  1. Does the use case cleanly map to one verb? "Charge a subscription," "register a user," "approve an order." Use an Action. One class, one method, named for the use case.
  2. Does the work coordinate multiple domains around an external system or a long-lived state? Stripe, Slack, S3, an in-memory cart, a multi-step wizard. A Service class with cohesive methods earns its keep.
  3. Are you tempted to write a "wrapper" that doesn't add behavior? A repository wrapping Eloquent, a service wrapping a model, a manager wrapping a service. Don't. The wrapper has zero value until it has logic of its own.

The fourth check, implicit in all of them: read the existing code first. If the team has already shipped fifty Actions, your billing module's odd-shape coordination class is the exception, not the new convention. Match what's already there.

What About Repositories?

While we're here. The "Repository pattern in Laravel" gets argued about every six months. The honest answer:

  • Eloquent is already a repository. User::where(...)->get() is the same call shape as $users->where(...)->get() on an injected collection.
  • The argument "we might swap databases" is almost never true. Even when it is, swapping from MySQL to Postgres rarely requires a new abstraction layer; it requires migration work.
  • A read-side query class is fine when a model has accumulated dozens of static query helpers. Call it UserQuery, OrderListing, InvoiceFinder — anything but UserRepository. The naming alone discourages turning it into a write-side wrapper.

If your team ships repositories without deriving any actual benefit, you're paying a tax for an abstraction that isn't doing anything. Stop, and use Eloquent directly.

A One-Sentence Mental Model

A Laravel service layer earns its keep when it coordinates multiple domains around an external system or holds long-lived state — StripeBillingService, CartService, OnboardingService — and becomes dead weight when it's a wrapper around a single Eloquent call; for the common case of "this use case is too big for a controller," reach for an Action with one verb method, not a service class with seventeen.