Service Classes — Stateless Business Logic

Service Classes — Stateless Business Logic

Reading Time: 5 minutes

Series: Every Laravel Project Should Have These Building Blocks 
Part: 8 of 35 | Level: Intermediate | Prerequisites: Thin Controllers


What You’ll Learn

  • What a Service class is and what it is not
  • The stateless rule and why it matters
  • How to organize services by domain
  • When to use a Service vs. an Action
  • How to test service classes
  • The Services/Core/ infrastructure pattern

What Is a Service Class?

A Service is a class that encapsulates a body of business logic that is too complex for a single Action, involves multiple models or external systems, or needs to be called from multiple places.

Services are stateless: they don’t hold data between method calls. They receive everything they need as arguments and return results. They have no $this->currentUser or $this->cachedResult properties.

// ✅ Stateless — everything comes in, everything goes out
final class OrderService
{
    public function __construct(
        private readonly StripePaymentServiceInterface $stripe,
        private readonly InventoryService $inventory,
    ) {}

    public function processPaymentAndReserveStock(
        Order $order,
        string $paymentIntentId
    ): PaymentResult {
        $result = $this->stripe->confirm($paymentIntentId);

        if ($result->succeeded()) {
            $this->inventory->reserveForOrder($order);
        }

        return $result;
    }
}

// ❌ Stateful — holds data between calls, causes bugs
final class OrderService
{
    private Order $currentOrder; // ← DON'T DO THIS

    public function setOrder(Order $order): void
    {
        $this->currentOrder = $order;
    }

    public function process(): void
    {
        // uses $this->currentOrder — but what if setOrder() wasn't called?
    }
}

Organizing Services by Domain

When you have more than 2–3 services, group them in subdirectories by the domain they belong to:

app/Services/
├── Core/
│   └── ErrorReporter.php      # Cross-cutting infrastructure
│
├── Status/
│   ├── StatusService.php
│   ├── Contracts/
│   │   ├── StatusCheckInterface.php
│   │   └── StatusServiceInterface.php
│   └── Checks/
│       ├── DatabaseCheck.php
│       ├── CacheCheck.php
│       └── QueueCheck.php
│
├── Order/
│   ├── OrderService.php
│   ├── OrderPaymentService.php
│   └── CouponValidationService.php
│
├── Stripe/
│   ├── StripePaymentService.php
│   └── StripePaymentServiceInterface.php
│
└── Ai/
    ├── OllamaService.php
    ├── OllamaServiceInterface.php
    ├── EmbeddingService.php
    └── ChatContextService.php

The domain grouping has two benefits: you can find services by feature, and you can see at a glance how complex each domain is.


Service with a Contract (Interface)

When a service talks to an external system (Stripe, AWS, a third-party API), back it with an interface. This lets you swap the implementation in tests or if you change providers.

// app/Services/Stripe/StripePaymentServiceInterface.php
<?php

declare(strict_types=1);

namespace App\Services\Stripe;

interface StripePaymentServiceInterface
{
    public function createPaymentIntent(int $amountInCents, string $currency): PaymentIntent;

    public function confirmPayment(string $paymentIntentId): PaymentResult;

    public function refund(string $chargeId, ?int $amountInCents = null): Refund;
}
// app/Services/Stripe/StripePaymentService.php
<?php

declare(strict_types=1);

namespace App\Services\Stripe;

use Stripe\StripeClient;

final class StripePaymentService implements StripePaymentServiceInterface
{
    private StripeClient $client;

    public function __construct()
    {
        $this->client = new StripeClient(config('services.stripe.secret'));
    }

    public function createPaymentIntent(int $amountInCents, string $currency): PaymentIntent
    {
        $intent = $this->client->paymentIntents->create([
            'amount'   => $amountInCents,
            'currency' => $currency,
        ]);

        return new PaymentIntent(id: $intent->id, clientSecret: $intent->client_secret);
    }

    // ...
}

Bind in AppServiceProvider::register():

$this->app->singleton(StripePaymentServiceInterface::class, StripePaymentService::class);

Service with Multiple Responsibilities

When a domain has multiple responsibilities, split them into separate service classes rather than one large class:

// app/Services/ServiceRequest/
// ├── CreationService.php       — creates a new service request
// ├── PlanService.php           — assigns tasks and capacity
// ├── ReplanService.php         — re-plans an existing request
// ├── RejectionService.php      — handles the rejection workflow
// ├── EditService.php           — handles edits to a draft request
// ├── ReportService.php         — generates reports
// └── ProcessCompletionService.php — marks processes complete

Each class has one responsibility. If you need to change how planning works, you touch PlanService — nothing else.

// app/Services/ServiceRequest/CreationService.php
final class CreationService
{
    public function __construct(
        private readonly CustomerRepository $customers,
        private readonly NotificationService $notifications,
    ) {}

    public function create(Merchandiser $merchandiser, array $data): ServiceRequest
    {
        return DB::transaction(function () use ($merchandiser, $data): ServiceRequest {
            $serviceRequest = ServiceRequest::create([
                ...$data,
                'created_by' => $merchandiser->id,
                'status'     => ServiceRequestStatus::Draft,
            ]);

            $this->notifications->notifyAdminsOfNewRequest($serviceRequest);

            return $serviceRequest;
        });
    }
}

Service vs. Action — When to Use Each

This is the question developers ask most. Here’s the rule:

Use an Action when…Use a Service when…
The operation is atomic (one thing)The operation coordinates multiple things
It’s called from one placeIt’s called from multiple places
It has a clear before/after stateIt runs complex computations or queries
It wraps a DB transactionIt talks to external APIs
Example: CreateOrderActionExample: OrderPaymentService

An Action typically calls a Service, not the other way around.

// Action orchestrates the high-level flow
final class CheckoutAction
{
    public function __construct(
        private readonly CartService $cart,
        private readonly CreateOrderAction $createOrder,
        private readonly StripePaymentServiceInterface $stripe,
    ) {}

    public function execute(User $user, OrderCustomerData $data): Order
    {
        $cartItems = $this->cart->getItems($user);

        $paymentIntent = $this->stripe->createPaymentIntent(
            $this->cart->calculateTotal($cartItems),
            'usd'
        );

        return $this->createOrder->execute($user, $data, $cartItems);
    }
}

Services/Core/ — Infrastructure Services

Services/Core/ is for services that cut across the entire application and have no specific domain. The most important one is ErrorReporter:

// app/Services/Core/ErrorReporter.php
final class ErrorReporter
{
    public static function report(Throwable $e): void
    {
        // 1. Check we have recipients
        $recipients = self::validRecipients();
        if ($recipients === []) {
            return;
        }

        // 2. Rate-limit: deduplicate the same exception within 5 minutes
        $key = 'exception:' . hash('sha256', $e->getMessage() . $e->getFile());
        if (Cache::add($key, true, now()->addMinutes(5)) === false) {
            return;
        }

        // 3. Build the payload and email
        Mail::to($recipients)->send(new ExceptionOccurred(self::buildPayload($e)));
    }
}

It’s a static utility class, not a traditional service — but it belongs in Services/Core/ because it’s a core infrastructure concern, not a domain concept.


Testing Services

Because services are stateless and receive their dependencies via constructor injection, they’re easy to test:

// tests/Unit/OrderServiceTest.php
class OrderServiceTest extends TestCase
{
    #[Test]
    public function it_creates_order_with_correct_total(): void
    {
        // Arrange
        $stripe = Mockery::mock(StripePaymentServiceInterface::class);
        $stripe->shouldReceive('createPaymentIntent')
               ->with(5000, 'usd')
               ->andReturn(new PaymentIntent('pi_123', 'secret_123'));

        $service = new OrderService($stripe);

        // Act
        $intent = $service->initiatePayment(50_00); // $50.00 in cents

        // Assert
        $this->assertEquals('pi_123', $intent->id);
    }
}

No HTTP requests, no database calls in unit tests. Fast, reliable, focused.


Key Takeaways

  • Services are stateless — they receive input, return output, and don’t store state between calls.
  • Group services by domain in subdirectories: Services/Order/Services/Stripe/Services/Core/.
  • Back external service integrations with an interface so you can swap them in tests.
  • Services/Core/ is for infrastructure concerns that cut across the whole application.
  • When a service grows beyond one cohesive responsibility, split it: CreationServiceEditServiceReportService.
  • Use an Action for atomic operations; use a Service for coordination, computation, and integration.


Frequently Asked Questions

When should I use a Service vs an Action? Use an Action for a single named operation (CreateOrderRejectRequest) that wraps a DB transaction and fires an event. Use a Service when you need reusable business logic that multiple Actions or commands share, or when you’re integrating with an external API. The short rule: Action = do this thing once; Service = this knowledge is used in many places.

Should every Service have an interface? Only when the Service talks to an external system (Stripe, AWS, a third-party API) or when you need to swap implementations in tests without real HTTP calls. Internal domain services that only touch your own database don’t need interfaces — the overhead isn’t worth it.

Can a Service call another Service? Yes. OrderService can inject and call StripePaymentService or InventoryService. Just keep the dependency graph acyclic — no circular dependencies. If ServiceA needs ServiceB and ServiceB needs ServiceA, you have a design problem, not a PHP problem.


Tips and Gotchas

⚠️ Warning: Stateful services are a hidden bug. If your service stores $this->currentUser or $this->requestData, it will break when the same service instance is reused across requests (e.g., in queue workers or after auth()->login() is called multiple times in a test). Keep services stateless — pass data as method parameters.

💡 Tip: Services bound as singleton in the container should be stateless. Services that need per-request state should use bind() (a new instance per resolve) or accept state as constructor arguments and be resolved fresh each time.

🔥 Expert Note: Always bind service interfaces in the container, not concrete classes. $this->app->bind(PaymentServiceInterface::class, StripePaymentService::class) lets you swap implementations without touching any calling code — critical for testing and vendor migration.

Further Reading


← Route Organisation | Next: Action Classes →

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

2 Comments