Service Layer pattern — where business logic lives
Concept
The Service Layer pattern introduces a layer of application services that sits between your HTTP layer (controllers, commands) and your domain/infrastructure layer (models, repositories, external APIs). A service encapsulates a single application use case — "publish a post," "process a refund," "register a user" — and coordinates all the steps needed to complete it. The controller's job shrinks to: validate input, call the service, return a response.
The key distinction between a Service Layer and an Anemic Model is intent. An Anemic Model is an anti-pattern where domain objects are just data bags with no behaviour, and all business logic leaks into services. A healthy Service Layer coordinates domain objects that have their own behaviour. The service asks domain objects to do things ("post.publish()", "order.applyDiscount()") rather than doing everything to them ("post.status = 'published'; post.published_at = now()").
Services should be stateless. Each method call should be self-contained — it receives all inputs as parameters, does its work, and returns a result. Stateless services are trivial to test (no setup beyond dependencies) and safe to use in a dependency injection container as singletons. If you find yourself storing state on a service between calls, you likely have a design problem.
The boundary question — "what belongs in a service vs a model method vs a controller?" — has a useful heuristic. Model methods express rules about a single entity's own state (is this order overdue?). Service methods express application-level use cases that may involve multiple entities, external systems, or side effects (send an email, fire an event, charge a credit card). Controller methods handle HTTP protocol concerns only (parse request, call service, format response, set status codes).
| Layer | Knows about | Does not know about |
|---|---|---|
| Controller | HTTP request/response | Business rules, database |
| Service | Application use cases, domain objects | HTTP, request format |
| Model/Domain | Entity rules, invariants | Services, controllers |
| Repository | Persistence, queries | Business rules |
Code Example
<?php
declare(strict_types=1);
// app/Services/UserRegistrationService.php
namespace App\Services;
use App\Events\UserRegistered;
use App\Exceptions\EmailAlreadyTakenException;
use App\Models\User;
use App\Notifications\WelcomeNotification;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
// A service encapsulates a use case. All steps are coordinated here.
// It is injected — never instantiated with `new` in caller code.
final class UserRegistrationService
{
public function __construct(
private readonly EmailVerificationService $emailVerification,
) {}
public function register(
string $name,
string $email,
string $password,
): User {
// 1. Guard against duplicates — this is a business rule, not a DB constraint check
if (User::where('email', $email)->exists()) {
throw new EmailAlreadyTakenException($email);
}
// 2. Wrap multi-step side-effects in a transaction
$user = DB::transaction(function () use ($name, $email, $password) {
$user = User::create([
'name' => $name,
'email' => $email,
'password' => Hash::make($password),
]);
// Coordination: call other services, fire events
$this->emailVerification->sendVerificationEmail($user);
return $user;
});
// Events after transaction — if the DB commit fails, the event is not dispatched
event(new UserRegistered($user));
$user->notify(new WelcomeNotification());
return $user;
}
}
// app/Http/Controllers/Auth/RegisterController.php
// Controller is now 100% HTTP plumbing — no business logic
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\RegisterRequest;
use App\Services\UserRegistrationService;
use Illuminate\Http\JsonResponse;
class RegisterController extends Controller
{
public function __construct(
private readonly UserRegistrationService $registration,
) {}
public function __invoke(RegisterRequest $request): JsonResponse
{
try {
$user = $this->registration->register(
name: $request->validated('name'),
email: $request->validated('email'),
password: $request->validated('password'),
);
} catch (\App\Exceptions\EmailAlreadyTakenException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json(['user' => $user], 201);
}
}
// tests/Unit/Services/UserRegistrationServiceTest.php
// Service is testable without an HTTP request
namespace Tests\Unit\Services;
use App\Exceptions\EmailAlreadyTakenException;
use App\Models\User;
use App\Services\EmailVerificationService;
use App\Services\UserRegistrationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;
class UserRegistrationServiceTest extends TestCase
{
use RefreshDatabase;
public function test_registers_user_and_dispatches_event(): void
{
$this->expectsEvents(\App\Events\UserRegistered::class);
$verifier = Mockery::mock(EmailVerificationService::class);
$verifier->shouldReceive('sendVerificationEmail')->once();
$service = new UserRegistrationService($verifier);
$user = $service->register('Alice', 'alice@example.com', 'secret');
$this->assertDatabaseHas('users', ['email' => 'alice@example.com']);
$this->assertSame('Alice', $user->name);
}
public function test_throws_when_email_already_taken(): void
{
User::factory()->create(['email' => 'taken@example.com']);
$this->expectException(EmailAlreadyTakenException::class);
$verifier = Mockery::mock(EmailVerificationService::class);
$service = new UserRegistrationService($verifier);
$service->register('Bob', 'taken@example.com', 'secret');
}
}Interview Q&A
Q: Where should business logic live in a Laravel application — the model, the controller, or a service?
Business logic that is intrinsic to a single entity belongs on the model ("is this subscription expired?"). Application-level orchestration that touches multiple models, fires events, sends notifications, or talks to external systems belongs in a service. Controllers handle only HTTP protocol concerns: parsing the request, calling the service, and returning the right status code and response format. This separation keeps each layer testable in isolation and limits the blast radius when requirements change.
Q: Should services be stateless, and why?
Yes. A service should accept all inputs as parameters to its methods and return results without storing state between calls. Stateless services are safe to register as singletons in the service container (Laravel's default binding behaviour), meaning no memory leaks from accumulated state across requests. They are also far simpler to test — you instantiate the service, call a method, and assert the result, with no need to reset internal state between test cases.
Q: What is the difference between an Application Service and a Domain Service?
An Application Service (like UserRegistrationService) lives at the application layer — it coordinates the use case, handles transactions, fires events, sends emails, and speaks to external systems. It knows about infrastructure. A Domain Service encapsulates domain logic that does not naturally belong to a single entity — for example, a TransferService that applies a money transfer between two Account objects according to business rules, without knowing about databases or HTTP. Domain Services are pure domain logic with no infrastructure dependencies, making them completely framework-agnostic.