Service Classes — Stateless Business Logic
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 place | It’s called from multiple places |
| It has a clear before/after state | It runs complex computations or queries |
| It wraps a DB transaction | It talks to external APIs |
Example: CreateOrderAction | Example: 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:
CreationService,EditService,ReportService. - 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 (CreateOrder, RejectRequest) 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->currentUseror$this->requestData, it will break when the same service instance is reused across requests (e.g., in queue workers or afterauth()->login()is called multiple times in a test). Keep services stateless — pass data as method parameters.
💡 Tip: Services bound as
singletonin the container should be stateless. Services that need per-request state should usebind()(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.
2 Comments