If you’ve ever felt a controller growing too large, or found yourself duplicating complex logic across jobs and listeners, you’ve met the moment where a service class earns its keep. Service classes give Laravel apps a clean, testable “domain” layer: a place where business logic lives independently of HTTP, CLI, or queue concerns. They make code easier to read, safer to change, and simpler to reuse.
In this guide, you’ll learn what service classes are (and aren’t), when to introduce them, how to structure and test them, and how to wire them cleanly with Laravel’s service container. You’ll also see a realistic, production-ready example with transactions, events, caching, and configuration—plus battle-tested best practices and pitfalls to avoid.
What is a Laravel Service Class?
A service class encapsulates business logic around a coherent capability—think “checkout,” “invoice generation,” “user onboarding,” or “subscription management.” Instead of packing logic into controllers, models, or listeners, you centralize it in a class that is:
- Stateless (ideally) and focused on one responsibility
- Injectable (via constructor dependencies)
- Independent of transport layers (HTTP, CLI, queue)
- Easy to test in isolation
Services are not a replacement for Eloquent models, Form Requests, or Jobs. They complement them by orchestrating use cases that often span multiple models, tools, and side effects.
Why a Service Layer? (Benefits and Signals)
A service layer provides:
- Separation of concerns: controllers handle IO; services handle business rules.
- Reuse: the same service can be used from controllers, jobs, commands, and events.
- Testability: mock external systems, assert outcomes without booting the full app stack.
- Evolvability: change implementation behind a stable interface.
Signals you need a service:
- Controllers over 100–200 lines with multiple decision paths.
- Copy-pasted logic across endpoints and jobs.
- Model methods starting to include API calls, emails, or payments (fat models).
- Difficulties writing unit tests that don’t hit the network or the database.
Folder Structure and Naming
There’s no single “right” structure, but consistency wins.
Common, simple approach:
- app/Services/CheckoutService.php
- app/Services/Billing/InvoiceService.php
- app/Services/Users/OnboardingService.php
If you prefer domain-first structure:
- app/Domain/Checkout/CheckoutService.php
- app/Domain/Billing/InvoiceService.php
Name services by capability (CheckoutService), not by pattern (DoEverythingService). Keep files small and cohesive.
A Real-World Example: Checkout in an E‑commerce Project
We’ll implement a CheckoutService that:
- Validates the cart and inventory
- Charges via a payment gateway interface
- Creates an order inside a database transaction
- Dispatches an “OrderPlaced” event
- Returns a result DTO
1) Define a gateway contract
<?php
namespace App\Contracts;
interface PaymentGateway
{
public function charge(int $amountCents, string $currency, array $meta = []): string;
// Returns a payment reference/ID on success, throws domain exceptions on failure
}
2) Stripe implementation (example)
<?php
namespace App\Gateways;
use App\Contracts\PaymentGateway;
use Stripe\StripeClient;
use RuntimeException;
class StripePaymentGateway implements PaymentGateway
{
public function __construct(
private StripeClient $client,
private string $statementDescriptor
) {}
public function charge(int $amountCents, string $currency, array $meta = []): string
{
$intent = $this->client->paymentIntents->create([
'amount' => $amountCents,
'currency' => $currency,
'metadata' => $meta,
'statement_descriptor' => $this->statementDescriptor,
'confirm' => true,
'automatic_payment_methods' => ['enabled' => true],
]);
if ($intent->status !== 'succeeded') {
throw new RuntimeException('Payment failed: '.$intent->status);
}
return $intent->id;
}
}
3) A simple DTO for checkout input/output
<?php
namespace App\DTO;
class CheckoutResult
{
public function __construct(
public readonly int $orderId,
public readonly string $paymentId,
public readonly int $totalCents,
public readonly string $currency
) {}
}
4) The CheckoutService (business orchestration)
<?php
namespace App\Services;
use App\Contracts\PaymentGateway;
use App\DTO\CheckoutResult;
use App\Events\OrderPlaced;
use App\Models\Cart;
use App\Models\Order;
use App\Models\Product;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use RuntimeException;
class CheckoutService
{
public function __construct(
private PaymentGateway $payments
) {}
public function checkout(int $userId, string $currency = 'usd'): CheckoutResult
{
return DB::transaction(function () use ($userId, $currency) {
$cart = Cart::forUser($userId)->with('items.product')->firstOrFail();
if ($cart->items->isEmpty()) {
throw new RuntimeException('Cart is empty.');
}
$totalCents = 0;
foreach ($cart->items as $item) {
/** @var Product $product */
$product = $item->product;
if ($product->stock < $item->quantity) {
throw new RuntimeException("Insufficient stock for {$product->name}.");
}
$totalCents += (int) round($product->price_cents * $item->quantity);
}
$paymentId = $this->payments->charge($totalCents, $currency, [
'user_id' => (string) $userId,
'cart_id' => (string) $cart->id,
]);
// Reserve/adjust stock
foreach ($cart->items as $item) {
$item->product->decrement('stock', $item->quantity);
}
$order = Order::create([
'user_id' => $userId,
'total_cents'=> $totalCents,
'currency' => $currency,
'payment_id' => $paymentId,
'status' => 'paid',
]);
Event::dispatch(new OrderPlaced($order->id));
// Optionally clear the cart
$cart->items()->delete();
return new CheckoutResult($order->id, $paymentId, $totalCents, $currency);
});
}
}
5) Bind the contract in a service provider
<?php
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Gateways\StripePaymentGateway;
use Illuminate\Support\ServiceProvider;
use Stripe\StripeClient;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(PaymentGateway::class, function ($app) {
$client = new StripeClient(config('services.stripe.secret'));
return new StripePaymentGateway(
client: $client,
statementDescriptor: config('app.name', 'My Shop')
);
});
}
}
Tip: Add Stripe keys to config/services.php and .env, not hardcoded in code.
6) Use the service from a controller
<?php
namespace App\Http\Controllers;
use App\Services\CheckoutService;
use Illuminate\Http\Request;
class CheckoutController extends Controller
{
public function __construct(
private CheckoutService $checkout // auto-resolved by container
) {}
public function store(Request $request)
{
$result = $this->checkout->checkout($request->user()->id);
return response()->json([
'order_id' => $result->orderId,
'payment_id' => $result->paymentId,
'total' => $result->totalCents,
'currency' => $result->currency,
'message' => 'Order placed successfully.',
]);
}
}
7) Unit test the service with a fake gateway
<?php
namespace Tests\Unit\Services;
use App\Contracts\PaymentGateway;
use App\Models\Cart;
use App\Models\Order;
use App\Models\Product;
use App\Services\CheckoutService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;
class CheckoutServiceTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function it_creates_an_order_and_charges_payment()
{
$user = \App\Models\User::factory()->create();
$product = Product::factory()->create(['stock' => 10, 'price_cents' => 2999]);
$cart = Cart::factory()->for($user)->create();
$cart->items()->create(['product_id' => $product->id, 'quantity' => 2]);
$gateway = Mockery::mock(PaymentGateway::class);
$gateway->shouldReceive('charge')
->once()
->with(5998, 'usd', Mockery::type('array'))
->andReturn('pi_12345');
$service = new CheckoutService($gateway);
$result = $service->checkout($user->id, 'usd');
$this->assertDatabaseHas('orders', [
'id' => $result->orderId,
'payment_id' => 'pi_12345',
'total_cents' => 5998,
'status' => 'paid',
]);
}
}
This example demonstrates the strengths of a service layer: clean orchestration, easy testing, and swappable integrations.
Using Services with Queues, Events, Caching, and Policies
- Jobs/Queues: Keep jobs thin—deserialize data, then call the service. That way, the same logic is reusable from HTTP or CLI.
- Events: Emit domain events (e.g., OrderPlaced) from the service; listeners handle email, fulfillment, analytics—keeping the service focused.
- Caching: Cache read-heavy computations inside services using
Cache::remember(). Avoid caching writes; cache results, not process steps.
- Policies/Authorization: Check authorization in controllers or Form Requests before calling the service; keep services focused on business rules.
Best Practices for Laravel Service Classes
- Single responsibility: One clear capability per service. Split if the class grows beyond ~200–300 lines or has multiple reasons to change.
- Prefer interfaces at boundaries: Define contracts for external systems (payments, messaging, search). Bind implementations in providers.
- Constructor injection > facades: Makes dependencies explicit and testable. Facades are fine in outer layers; prefer DI in services.
- Transactions where state must be consistent: Use
DB::transaction() to wrap multi-model updates with external side effects carefully ordered.
- Idempotency: For operations like payments or webhooks, design for safe retries (idempotency keys, unique constraints).
- Validation strategy: Validate inputs at the boundary (FormRequest, DTO). Services should assume validated, normalized data.
- Configuration via config(): Read secrets and toggles from config and
.env. Never hardcode credentials.
- Logging and observability: Log meaningful events and add metrics for duration, failures, and retries. Helps you debug in production.
- Avoid anemic domain: Keep rich, meaningful methods on models where appropriate (e.g.,
Order::markAsPaid()), and let services orchestrate across entities.
- Keep services stateless: Don’t store request-specific state; pass everything as parameters or DTOs.
Common Pitfalls (and How to Avoid Them)
- God services: If a service coordinates too many concerns, split it into smaller services or use case classes.
- Hidden side effects: Document and test all side effects (emails, queues, external calls). Use events to decouple where helpful.
- Tight coupling to frameworks: Avoid reading from
Request or returning Response objects inside services; keep transport-agnostic.
- Over-mocking: Mock only external boundaries. For internal collaborators, consider using real implementations with an in-memory database during tests.
- Premature abstraction: Don’t create a service because “we might need it.” Extract when duplication or complexity justifies it.
When Not to Use a Service
- One-line delegations that belong naturally on a model (e.g., computed attributes).
- Simple controller logic that’s tightly coupled to HTTP semantics (e.g., toggling a UI preference).
- Pure data retrieval with no orchestration (consider a Query Object or Repository if it adds clarity).
Performance, Types, and PHP 8+ Features
- Add scalar and return types throughout services to catch errors early.
- Use readonly properties where possible.
- Consider
enum types for statuses/currencies to reduce magic strings.
- Batch queries, eager load relationships, and prefer
chunk()/cursor() for large datasets.
Bringing It Together: A Repeatable Pattern
- Keep controllers thin.
- Push orchestration to services with explicit dependencies.
- Wrap consistency-critical flows in transactions.
- Emit events for cross-cutting concerns.
- Bind abstractions in service providers.
- Test services in isolation; integration-test seams.
- Observe in production (logs, metrics, traces).
This pattern scales from side projects to enterprise Laravel applications, giving you a maintainable architecture that’s easy to evolve.
Final Thoughts
Laravel service classes aren’t about ceremony; they’re about clarity. By giving business logic a well-defined home—independent from controllers, jobs, and listeners—you’ll ship faster, test with confidence, and change requirements without fear. Start with your most complex use case, extract a service behind a small interface, and let Laravel’s container do the heavy lifting. Over time, you’ll find a service layer becomes the backbone of a clean, resilient Laravel codebase.