← Back to Dashboard

Service Pattern

Laravel: app/Services/ directory convention, thin controllers, Cashier, Socialite

The Service Pattern

Note: The Service pattern is not one of the original Gang of Four (GoF) design patterns. It is a widely adopted architectural pattern in Laravel and modern PHP applications.

The Real-World Analogy

Think about what happens when you walk into a bank to send a wire transfer. You don't personally validate the account numbers, check the exchange rate, contact the receiving bank, deduct the fee, and print the receipt yourself. You go to a teller -- a dedicated service person -- who orchestrates the entire process on your behalf. You (the controller) say "send this transfer," and the teller (the service) handles all the steps.

Now imagine a bank with no tellers, where the front desk receptionist personally handles wire transfers, account openings, loan applications, and complaint resolution. That receptionist is your "fat controller" -- doing everything, understanding nothing deeply, and impossible to replace.

The Service pattern gives your application dedicated tellers: classes whose sole job is orchestrating a specific piece of business logic.

The Anti-Pattern: The Fat Controller

This is one of the most common code smells in Laravel applications. Everything ends up in the controller:

class PaymentController extends Controller
{
    public function store(Request $request)
    {
        // Validation logic
        $amount = (float) $request->input('amount');
        $currency = $request->input('currency');
        if ($amount <= 0) {
            return response()->json(['error' => 'Invalid amount'], 422);
        }
        if (!in_array($currency, ['PHP', 'USD'])) {
            return response()->json(['error' => 'Unsupported currency'], 422);
        }

        // Processing logic
        $transactionId = 'TXN-' . strtoupper(substr(md5((string)time()), 0, 8));
        $fee = $amount * 0.025;
        $total = $amount + $fee;

        // Receipt generation logic
        $receipt = "=== PAYMENT RECEIPT ===\n";
        $receipt .= "Transaction: {$transactionId}\n";
        $receipt .= "Amount: {$currency} " . number_format($amount, 2) . "\n";
        $receipt .= "Fee: {$currency} " . number_format($fee, 2) . "\n";
        $receipt .= "Total: {$currency} " . number_format($total, 2) . "\n";

        // Notification logic
        mail('finance@company.com', "Payment {$transactionId}", $receipt);

        return response()->json([
            'transaction_id' => $transactionId,
            'receipt' => $receipt,
        ]);
    }
}

Why this is a problem:

  1. Untestable. To test the payment logic, you have to simulate an HTTP request. You can't test the validation, processing, or receipt generation independently.
  2. Not reusable. If a CLI command also needs to process payments, you'd have to duplicate this entire block. Copy-paste leads to divergence.
  3. Hard to read. A 50-line controller method that mixes HTTP concerns, business rules, and side effects is hard to understand at a glance.
  4. Violates Single Responsibility. This controller is responsible for HTTP handling, validation, business logic, and notification -- at least four different reasons to change.

The Fix: Extract a Service Class

Move the business logic into a dedicated service. The controller's only job is translating HTTP input/output:

class PaymentService
{
    public function __construct(
        private PaymentValidator $validator,
        private PaymentProcessor $processor,
        private ReceiptGenerator $receiptGenerator,
    ) {}

    public function executePayment(float $amount, string $currency): array
    {
        $this->validator->validate($amount, $currency);
        $transaction = $this->processor->process($amount, $currency);
        $receipt = $this->receiptGenerator->generate($transaction);

        return [
            'transaction_id' => $transaction['id'],
            'receipt' => $receipt,
        ];
    }
}

The controller becomes thin:

class PaymentController extends Controller
{
    public function store(Request $request, PaymentService $paymentService)
    {
        $result = $paymentService->executePayment(
            (float) $request->input('amount'),
            $request->input('currency'),
        );

        return response()->json($result);
    }
}

Now each piece is testable independently. The service is reusable from any entry point (controller, command, job). And the controller is easy to read -- it receives a request and returns a response.

When to Use a Service

Use a service when:

  • Business logic spans multiple steps (validate, process, notify)
  • The same logic is needed from multiple entry points (web, CLI, queue)
  • The logic is complex enough to warrant its own tests
  • You find yourself wanting to call controller methods from other controllers

When a Service Is Overkill

Not everything needs a service class. Simple CRUD operations that map directly to a single model operation are fine in the controller:

// This is fine -- no need for a "UserUpdateService"
public function update(Request $request, User $user)
{
    $user->update($request->validated());
    return redirect()->back();
}

The rule of thumb: if your controller method is more than 10-15 lines, or if it orchestrates multiple collaborators, extract a service.

Services Compose with DI

Notice that PaymentService accepts its dependencies through the constructor -- this is the DI pattern from the previous module in action. Services rarely work alone. They orchestrate other focused classes:

  • PaymentValidator -- knows the rules for valid payments
  • PaymentProcessor -- handles the actual transaction logic
  • ReceiptGenerator -- formats the output

Each of these could be an interface with swappable implementations. The service doesn't know or care about the specifics. It just calls the methods defined in the contracts.


Key takeaway: Controllers should translate between HTTP and your domain. Services should contain the actual business logic. This separation makes your code testable, reusable, and maintainable. When in doubt, ask: "Could I call this logic from an Artisan command without simulating an HTTP request?" If the answer is no, you need a service.

What You Learned

  • A Service class orchestrates a workflow by delegating to focused collaborators — it contains no business logic itself
  • Services keep controllers thin by extracting complex multi-step processes into a dedicated layer
  • Each collaborator (validator, processor, generator) has a single responsibility and can be tested independently
  • Services rely on dependency injection to receive their collaborators, making them flexible and testable

The Service Pattern in Laravel

Laravel doesn't enforce a specific "Service" base class or interface -- it's a convention. But it's one of the most widely used patterns in the Laravel ecosystem, and the framework's architecture is designed to support it beautifully.

The Convention: app/Services/

Most Laravel applications organize service classes under app/Services/:

app/
  Http/
    Controllers/
      PaymentController.php      ← Thin: receives request, calls service, returns response
  Services/
    PaymentService.php           ← Contains business logic
    NotificationService.php
    ReportingService.php

There's no artisan command to generate a service class -- you just create the file. Laravel's auto-loading (via Composer's PSR-4) picks it up automatically.

Thin Controllers in Practice

Here's what a typical Laravel controller looks like when following the service pattern:

// app/Http/Controllers/PaymentController.php

class PaymentController extends Controller
{
    public function __construct(
        private PaymentService $paymentService
    ) {}

    public function store(StorePaymentRequest $request): JsonResponse
    {
        // Controller's only job: translate HTTP ↔ domain
        $result = $this->paymentService->processPayment(
            amount: $request->validated('amount'),
            currency: $request->validated('currency'),
            method: $request->validated('payment_method'),
        );

        return response()->json($result, 201);
    }

    public function refund(string $transactionId): JsonResponse
    {
        $result = $this->paymentService->refundPayment($transactionId);
        return response()->json($result);
    }
}

The controller has no business logic. It validates input (via Form Request), calls the service, and formats the HTTP response. If you need the same payment logic in an Artisan command:

// app/Console/Commands/ProcessRefundCommand.php

class ProcessRefundCommand extends Command
{
    protected $signature = 'payment:refund {transactionId}';

    public function handle(PaymentService $paymentService): int
    {
        $result = $paymentService->refundPayment(
            $this->argument('transactionId')
        );

        $this->info("Refund processed: {$result['refund_id']}");
        return Command::SUCCESS;
    }
}

Same service, different entry point. No duplicated logic.

Real Laravel Ecosystem Examples

Laravel Cashier

Laravel Cashier (Stripe billing integration) is a service pattern in action. It provides service-like classes that encapsulate complex Stripe API interactions:

// Cashier handles all Stripe complexity behind clean methods
$user->newSubscription('default', 'price_monthly')
    ->trialDays(14)
    ->create($paymentMethod);

Behind this fluent API is a SubscriptionBuilder service that orchestrates Stripe customer creation, payment method attachment, and subscription creation.

Laravel Socialite

Socialite encapsulates OAuth complexity:

// Socialite's driver is essentially a service
$user = Socialite::driver('github')->user();

The AbstractProvider class handles token exchange, user info retrieval, and error handling. Your controller just calls ->user().

Service Providers: Wiring Services Together

When your services depend on interfaces, you register bindings in a service provider:

// app/Providers/PaymentServiceProvider.php

class PaymentServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(PaymentGatewayInterface::class, function ($app) {
            return match(config('payment.default_gateway')) {
                'stripe' => new StripeGateway(config('payment.stripe_key')),
                'gcash'  => new GCashGateway(config('payment.gcash_key')),
                default  => new NullGateway(),
            };
        });

        // PaymentService will be auto-resolved since its dependencies
        // (PaymentGatewayInterface) are now bound
        $this->app->singleton(PaymentService::class);
    }
}

Invokable Services (Single-Action Services)

For services that do exactly one thing, PHP's __invoke method is a clean pattern:

class CalculateTransactionFee
{
    public function __invoke(float $amount, string $method): float
    {
        return match($method) {
            'credit_card' => $amount * 0.029 + 0.30,
            'gcash'       => $amount * 0.02,
            'bank'        => 15.00,
            default       => 0.00,
        };
    }
}

// Usage
$fee = app(CalculateTransactionFee::class)(1000.00, 'gcash');

This is particularly common for query-like operations or calculations that don't need a full service class.

Testing Services

Services are straightforward to test because they're plain PHP classes:

class PaymentServiceTest extends TestCase
{
    public function test_successful_payment_returns_receipt(): void
    {
        $validator = new PaymentValidator();
        $processor = new FakePaymentProcessor(); // Test double
        $receipts  = new ReceiptGenerator();

        $service = new PaymentService($validator, $processor, $receipts);
        $result = $service->processPayment(100.00, 'PHP', 'gcash');

        $this->assertArrayHasKey('transaction_id', $result);
        $this->assertArrayHasKey('receipt', $result);
    }
}

No HTTP simulation needed. No mocking framework required for simple cases. Just instantiate with test doubles and assert.


Key takeaway: Laravel's service container, auto-loading, and dependency injection make the service pattern effortless. Create a class in app/Services/, type-hint its dependencies, and inject it anywhere. The framework handles the rest.

This example is pre-filled and runnable. Click "Run" to see the output. Feel free to modify the code and experiment.

Output

                

            

Complete the code below to pass all test cases. Use the hints if you get stuck.