Well-designed Laravel service classes are the difference between a test suite that runs in milliseconds and one you avoid writing entirely. The problem is the fat controller: a method that news up an HTTP client, calls `Stripe::charge()`, reaches for `Mail::to()`, and writes three Eloquent models, all in 60 lines. You cannot unit test that without a database, a mail server, and a live payment gateway. The fix is to pull that logic into a service class and inject its dependencies through the constructor, so a test can hand the service a fake gateway instead of the real network. This post shows the before/after, how to bind interfaces in a service provider, and how to test the result with Pest.
Why are fat controllers so hard to test?
Here is the kind of controller I inherit on almost every legacy project. It works in production, and it is effectively untestable in isolation because every collaborator is hard-wired with a facade or a `new` call.
public function store(Request $request)
{
$order = Order::create([
'user_id' => $request->user()->id,
'amount' => $request->integer('amount'),
]);
// Hard-wired gateway: a unit test would hit the real Stripe API.
$charge = Http::withToken(config('services.stripe.secret'))
->post('https://api.stripe.com/v1/charges', [
'amount' => $order->amount,
'currency' => 'usd',
]);
if ($charge->failed()) {
$order->update(['status' => 'failed']);
return back()->withErrors('Payment declined');
}
$order->update(['status' => 'paid']);
Mail::to($request->user())->send(new OrderPaid($order));
return redirect()->route('orders.show', $order);
}To test the decline path you would have to force the real Stripe call to fail. To test the success path you would charge a real card. The HTTP call, the mail send, and the persistence are all welded to the controller. There is no seam to inject a test double. This is exactly the kind of coupling the Dependency Inversion principle warns about, and I cover the whole family in SOLID principles in PHP with real examples.
How do I extract the logic into a service class?
The move is to define an interface for each thing you want to fake, then depend on that interface in a service class via constructor injection. The controller shrinks to parsing the request and calling one method. Start with the gateway contract so the network call lives behind a seam.
<?php
namespace App\Contracts;
interface PaymentGateway
{
/**
* Charge the given amount (in cents). Returns the gateway charge id.
*
* @throws \App\Exceptions\PaymentDeclined
*/
public function charge(int $amountInCents, string $currency = 'usd'): string;
}<?php
namespace App\Services;
use App\Contracts\PaymentGateway;
use App\Exceptions\PaymentDeclined;
use App\Mail\OrderPaid;
use App\Models\Order;
use App\Models\User;
use Illuminate\Contracts\Mail\Mailer;
class CheckoutService
{
public function __construct(
private readonly PaymentGateway $gateway,
private readonly Mailer $mailer,
) {}
public function checkout(User $user, int $amountInCents): Order
{
$order = Order::create([
'user_id' => $user->id,
'amount' => $amountInCents,
]);
try {
$chargeId = $this->gateway->charge($amountInCents);
} catch (PaymentDeclined $e) {
$order->update(['status' => 'failed']);
throw $e;
}
$order->update(['status' => 'paid', 'charge_id' => $chargeId]);
$this->mailer->to($user)->send(new OrderPaid($order));
return $order;
}
}The controller now resolves the service out of the container and does nothing else interesting. Because the constructor type-hints `CheckoutService`, Laravel's automatic resolution injects it and recursively resolves its own dependencies.
public function store(Request $request, CheckoutService $checkout)
{
try {
$order = $checkout->checkout($request->user(), $request->integer('amount'));
} catch (PaymentDeclined $e) {
return back()->withErrors('Payment declined');
}
return redirect()->route('orders.show', $order);
}Where do I bind the interface to a concrete class?
The container does not know which implementation to hand the service when it asks for a `PaymentGateway` interface. You tell it in the `register()` method of a service provider. Bind the contract to your real Stripe implementation there, and the whole graph wires itself up in production.
use App\Contracts\PaymentGateway;
use App\Services\Gateways\StripeGateway;
public function register(): void
{
$this->app->bind(PaymentGateway::class, StripeGateway::class);
}Use `bind()` for a fresh instance per resolution, or `singleton()` when the dependency holds expensive state like a configured HTTP client. The rule of thumb I follow:
- `bind()` — stateless services, repositories, anything cheap to construct.
- `singleton()` — clients that open a connection or read config once (an SDK client, a configured Guzzle wrapper).
- `instance()` — when you already have an object built and want the container to return that exact one (the trick that makes test doubles work, below).
Depend on interfaces for everything that crosses the network, and your tests stop needing the network.
How do I test the service with Pest?
There are two clean ways to get a fake into the service. The first is to skip the container entirely and construct the service yourself, passing a hand-written fake gateway. This is a true unit test: no HTTP, fast, and it asserts the decline path without a real declined card.
<?php
use App\Contracts\PaymentGateway;
use App\Exceptions\PaymentDeclined;
use App\Mail\OrderPaid;
use App\Models\User;
use App\Services\CheckoutService;
use Illuminate\Support\Facades\Mail;
it('marks the order paid when the gateway succeeds', function () {
Mail::fake();
$fakeGateway = new class implements PaymentGateway {
public function charge(int $amountInCents, string $currency = 'usd'): string
{
return 'ch_test_123';
}
};
$user = User::factory()->create();
$service = new CheckoutService($fakeGateway, app('mailer'));
$order = $service->checkout($user, 5000);
expect($order->status)->toBe('paid')
->and($order->charge_id)->toBe('ch_test_123');
Mail::assertSent(OrderPaid::class);
});
it('marks the order failed and rethrows when the gateway declines', function () {
$decliningGateway = new class implements PaymentGateway {
public function charge(int $amountInCents, string $currency = 'usd'): string
{
throw new PaymentDeclined('card_declined');
}
};
$user = User::factory()->create();
$service = new CheckoutService($decliningGateway, app('mailer'));
expect(fn () => $service->checkout($user, 5000))
->toThrow(PaymentDeclined::class);
expect($user->orders()->first()->status)->toBe('failed');
});The second way is to let the container build the service but swap the binding for a mock. This matters when you test through the HTTP layer (a feature test) where the controller resolves `CheckoutService` itself and you cannot reach into the constructor. Use `$this->mock()` to register a Mockery double, or `app()->instance()` to inject a prebuilt fake. Mockery lets you assert the gateway was called with the exact arguments.
<?php
use App\Contracts\PaymentGateway;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
it('charges the gateway and redirects on success', function () {
Mail::fake();
$this->mock(PaymentGateway::class, function ($mock) {
$mock->shouldReceive('charge')
->once()
->with(5000, 'usd')
->andReturn('ch_test_999');
});
$this->actingAs(User::factory()->create())
->post('/checkout', ['amount' => 5000])
->assertRedirect();
});Reach for Laravel's built-in fakes — `Mail::fake()`, `Bus::fake()`, `Queue::fake()`, `Event::fake()` — when the collaborator is a framework facade and you only need to assert that something was dispatched. Write your own interface and fake when the collaborator is a third-party boundary you own: a payment gateway, an SMS provider, a CRM client. The framework fakes give you `assertSent`/`assertDispatched`; your own interface gives you a swap point that also documents the contract. If your service dispatches queued work, the same injection discipline keeps your jobs testable too — see running Laravel queue workers with Supervisor in production.
When does this turn into over-engineering?
The failure mode is the god-service: an `OrderService` with 30 public methods that every controller depends on. It becomes a dumping ground, the constructor grows to eight dependencies, and the tests get slow because every one of them has to set up the whole world. A service per use case (`CheckoutService`, `RefundService`, `CancelOrderService`) keeps each class small, each constructor honest about what it needs, and each test focused. Do not extract an interface for something you will never fake — a value object or a pure calculation does not need a contract, it needs a method. Abstraction has a cost; pay it only at the boundaries that actually move (network, mail, queues, external APIs).
Start by finding the one controller method in your app that you are afraid to touch, pull its network and persistence logic into a service with constructor-injected interfaces, and write the two tests for the happy and failure paths. That single refactor usually exposes the bugs the old code was quietly swallowing. Once the seam exists, the same pattern composes everywhere, and your suite stays fast enough that you actually run it. While you are tightening up the boundaries, it is worth walking the Laravel security checklist — testable seams and a hardened surface tend to be the same refactors.

