Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 037 / 100

Service and action classes in Laravel: a practical guide

Where business logic should live.

Expona Admin7 min read

After a few months in production, your Laravel codebase starts showing the strain: controllers ballooning to 500 lines, models with 20 public methods, and tests that feel impossible to write because everything depends on everything else. Service and action classes in Laravel offer a practical guide to escape this trap, moving business logic into focused, testable units that scale with your application.

Fat models, fat controllers — Both become hard to test as apps grow

When you're building a feature quickly, it's tempting to pile everything into a model or controller. A model gets a method like processPayment(), which calls a payment gateway, logs a transaction, sends an email, and updates inventory. The controller handles validation, calls that model method, and returns a response. It works fine for the first few months.

The problem emerges when your test suite arrives. You want to test payment logic in isolation, but it's tangled with database saves, external API calls, and email delivery. You either mock half the application or skip the test. When you need the same logic in a queue job, an API endpoint, and a scheduled command, you duplicate code or shoehorn everything into the model.

Consider this typical scenario from a real SaaS application:

php
// App/Models/Order.php
public function processPayment($amount, $paymentMethod) {
    $result = PaymentGateway::charge($amount, $paymentMethod);
    
    if ($result->success) {
        $this->update(['status' => 'paid', 'paid_at' => now()]);
        $this->inventory->decrement('quantity', $this->quantity);
        Mail::send(new OrderConfirmation($this));
        Log::info('Order paid', ['order_id' => $this->id]);
        return true;
    }
    
    return false;
}

This method violates single responsibility, makes testing a nightmare, and couples your domain logic to framework details. When your payment provider changes, you touch the model. When you need to update how emails are queued, you touch the model. Services and actions help you extract this mess into focused, decoupled classes.

Action classes — One class per use case

An action class encapsulates a single, specific operation or use case: CreateOrder, RefundPayment, SendInvoice, CancelSubscription. Each action is a plain PHP class with a single public method, usually execute() or handle(), that takes the inputs it needs and returns the result.

Action classes live in app/Actions and have no inheritance from Laravel base classes. They accept dependencies via the constructor and call methods on those dependencies. This makes them testable without Laravel's container and reusable across any context: web requests, console commands, queue jobs, or webhooks.

Here's a practical example from a ride-hailing platform:

php
// App/Actions/CompleteRide.php
namespace App\Actions;

use App\Models\Ride;
use App\Contracts\LocationService;
use App\Contracts\PaymentService;
use App\Contracts\NotificationService;

class CompleteRide {
    public function __construct(
        private LocationService $locationService,
        private PaymentService $paymentService,
        private NotificationService $notificationService,
    ) {}

    public function execute(Ride $ride): array {
        if ($ride->status !== 'in_progress') {
            throw new \InvalidArgumentException('Ride is not in progress');
        }

        $finalLocation = $this->locationService->getCurrentLocation($ride->driver);
        $distance = $this->locationService->calculateDistance(
            $ride->pickup_location,
            $finalLocation
        );

        $fare = $this->paymentService->calculateFare(
            $distance,
            $ride->started_at
        );

        $ride->update([
            'status' => 'completed',
            'final_location' => $finalLocation,
            'distance' => $distance,
            'fare' => $fare,
            'completed_at' => now(),
        ]);

        $this->paymentService->charge($ride->passenger, $fare);
        $this->notificationService->notifyPassenger($ride, 'ride_completed');
        $this->notificationService->notifyDriver($ride, 'ride_completed');

        return [
            'ride_id' => $ride->id,
            'fare' => $fare,
            'distance' => $distance,
        ];
    }
}

Now your controller becomes trivial:

php
// App/Http/Controllers/RideController.php
public function complete(Request $request, CompleteRide $completeRide) {
    $ride = auth()->user()->ride;
    
    $result = $completeRide->execute($ride);
    
    return response()->json($result);
}

And testing is straightforward:

php
// Tests/Unit/Actions/CompleteRideTest.php
public function test_complete_ride_charges_passenger_and_notifies_both_users() {
    $ride = Ride::factory()->inProgress()->create();
    
    $locationService = Mockery::mock(LocationService::class);
    $locationService->shouldReceive('getCurrentLocation')->andReturn([13.5, 52.5]);
    $locationService->shouldReceive('calculateDistance')->andReturn(8.5);
    
    $paymentService = Mockery::mock(PaymentService::class);
    $paymentService->shouldReceive('calculateFare')->andReturn(25.50);
    $paymentService->shouldReceive('charge')->once();
    
    $notificationService = Mockery::mock(NotificationService::class);
    $notificationService->shouldReceive('notifyPassenger')->once();
    $notificationService->shouldReceive('notifyDriver')->once();
    
    $action = new CompleteRide($locationService, $paymentService, $notificationService);
    $result = $action->execute($ride);
    
    $this->assertEquals(25.50, $result['fare']);
    $this->assertEquals('completed', $ride->fresh()->status);
}

Action classes shine when a use case involves multiple steps and external dependencies. One action per use case keeps your code readable and focused.

While action classes handle single operations, services group related operations. A PaymentService might have methods like charge(), refund(), and retrieveTransaction(). A NotificationServicesendEmail(), sendSMS(), and sendPushNotification().

Services are useful when you have multiple ways to achieve something or when you need to switch implementations. They act as facades to complex subsystems and provide a consistent interface to your application.

Here's a payment service from an e-commerce application:

php
// App/Services/PaymentService.php
namespace App\Services;

use App\Contracts\PaymentGateway;
use App\Models\Payment;
use Stripe\Stripe;

class PaymentService {
    public function __construct(private PaymentGateway $gateway) {}

    public function charge(float $amount, string $currency, array $details): Payment {
        $transaction = $this->gateway->createCharge([
            'amount' => $amount,
            'currency' => $currency,
            'customer_id' => $details['customer_id'],
            'metadata' => $details,
        ]);

        return Payment::create([
            'amount' => $amount,
            'currency' => $currency,
            'external_id' => $transaction->id,
            'status' => 'success',
            'gateway' => class_basename($this->gateway),
        ]);
    }

    public function refund(Payment $payment): Payment {
        $this->gateway->refund($payment->external_id, $payment->amount);

        $payment->update(['status' => 'refunded', 'refunded_at' => now()]);

        return $payment;
    }

    public function verify(string $transactionId): bool {
        return $this->gateway->verify($transactionId);
    }
}

The gateway interface is injected, so swapping from Stripe to a competitor means changing one binding:

php
// App/Providers/AppServiceProvider.php
public function register() {
    $this->app->bind(PaymentGateway::class, match(config('payment.provider')) {
        'stripe' => StripeGateway::class,
        'paypal' => PayPalGateway::class,
        default => throw new \InvalidArgumentException('Unknown provider'),
    });
}

Services work best when you have a cohesive set of related operations that might have multiple implementations or when the logic is used across multiple actions. Use them to avoid duplication while keeping your code organized.

Testable — Plain classes with injected dependencies

The real power of actions and services comes from being testable. They're plain PHP classes with no Laravel magic, so you can instantiate them directly and pass mocks for their dependencies. No service container, no facades, no static methods.

This matters because tests run faster, feel simpler, and don't require setting up a full Laravel environment. You test the business logic in isolation from framework concerns.

A typical test for a service looks like this:

php
// Tests/Unit/Services/PaymentServiceTest.php
use App\Services\PaymentService;
use App\Contracts\PaymentGateway;

public function test_charge_creates_payment_record() {
    $gateway = Mockery::mock(PaymentGateway::class);
    $gateway->shouldReceive('createCharge')->andReturn((object) ['id' => 'tx_123']);
    
    $service = new PaymentService($gateway);
    $payment = $service->charge(100, 'USD', ['customer_id' => 1]);
    
    $this->assertEquals(100, $payment->amount);
    $this->assertEquals('tx_123', $payment->external_id);
    $this->assertDatabaseHas('payments', ['external_id' => 'tx_123']);
}

You control which dependencies are mocked and which are real. You can test error conditions without triggering actual external calls. The test is readable and fast because it doesn't touch the database or HTTP layer unless you explicitly want it to.

Common mistakes

Putting all logic in one god service: Don't create a UserService with 50 methods. That defeats the purpose. Keep services focused on related operations, and create new services when responsibilities diverge.

Using actions for simple CRUD: If all you're doing is creating a model and returning it, stick with a controller method or a formRequest. Actions shine when there's orchestration and side effects.

Returning models directly from actions: Return data arrays or DTOs instead. This decouples the action's output from your model structure and makes testing easier.

Injecting the container or facades: If your action accepts $container or calls Log::info(), you've lost the testability benefit. Inject concrete dependencies like a LoggerInterface.

Creating a service layer for every feature: Not every feature needs both an action and a service. Use actions when there's real business logic. Use services when you have multiple implementations or want to abstract a subsystem. Simpler features can stay in the controller.

Key takeaways

  • Move business logic out of models and controllers into dedicated action and service classes to keep them focused and testable.
  • Use action classes for single operations or use cases: one class, one public method, clear inputs and outputs.
  • Use service classes to group related operations and provide a stable interface to a subsystem, making it easy to swap implementations.
  • Keep actions and services as plain PHP classes with injected dependencies, no framework inheritance, so tests run fast and stay simple.
  • Test actions and services in isolation by mocking their dependencies; avoid testing framework integration in the same test.
  • Don't over-engineer: use these patterns where they solve a real problem, not for every line of code.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles