Service Layer in Laravel
LaravelThe Service Layer sits between controllers and repositories. Controllers handle HTTP (input, responses), repositories handle data access, and services handle business logic — things like sending emails, charging payments, or computing derived values.
Responsibility Split
| Layer | Responsibility | Example |
|---|---|---|
| Controller | HTTP input/output | Validate request, return JSON |
| Service | Business logic | Register user, send welcome email |
| Repository | Data access | INSERT into users table |
1. Folder Structure
Directory Layout
app/
├── Services/
│ └── UserService.php
├── Repositories/
│ ├── Contracts/
│ │ └── UserRepositoryInterface.php
│ └── Eloquent/
│ └── UserRepository.php
└── Http/
└── Controllers/
└── UserController.php2. Create the Service Class
app/Services/UserService.php
<?php
namespace App\Services;
use App\Models\User;
use App\Repositories\Contracts\UserRepositoryInterface;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use App\Mail\WelcomeEmail;
class UserService
{
public function __construct(
private readonly UserRepositoryInterface $users
) {}
public function register(array $data): User
{
$data['password'] = Hash::make($data['password']);
$user = $this->users->create($data);
Mail::to($user->email)->send(new WelcomeEmail($user));
return $user;
}
public function updateProfile(int $userId, array $data): bool
{
if (isset($data['password'])) {
$data['password'] = Hash::make($data['password']);
}
return $this->users->update($userId, $data);
}
public function deactivate(int $userId): bool
{
// Business rule: soft-delete and revoke tokens
$user = $this->users->find($userId);
if (!$user) {
return false;
}
$user->tokens()->delete();
return $this->users->delete($userId);
}
}3. Thin Controller
app/Http/Controllers/UserController.php
<?php
namespace App\Http\Controllers;
use App\Http\Requests\RegisterUserRequest;
use App\Http\Requests\UpdateUserRequest;
use App\Services\UserService;
use Illuminate\Http\JsonResponse;
class UserController extends Controller
{
public function __construct(
private readonly UserService $userService
) {}
public function store(RegisterUserRequest $request): JsonResponse
{
$user = $this->userService->register($request->validated());
return response()->json($user, 201);
}
public function update(UpdateUserRequest $request, int $id): JsonResponse
{
$this->userService->updateProfile($id, $request->validated());
return response()->json(['message' => 'Profile updated']);
}
public function destroy(int $id): JsonResponse
{
$this->userService->deactivate($id);
return response()->json(['message' => 'Account deactivated']);
}
}4. Form Requests for Validation
Keep validation in dedicated Form Request classes, not in the service:
app/Http/Requests/RegisterUserRequest.php
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class RegisterUserRequest extends FormRequest
{
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:users'],
'password' => ['required', 'min:8', 'confirmed'],
];
}
}5. Service With Multiple Dependencies
Services can depend on other services or external clients:
app/Services/OrderService.php
<?php
namespace App\Services;
use App\Repositories\Contracts\OrderRepositoryInterface;
use App\Repositories\Contracts\ProductRepositoryInterface;
use Illuminate\Support\Facades\DB;
class OrderService
{
public function __construct(
private readonly OrderRepositoryInterface $orders,
private readonly ProductRepositoryInterface $products,
private readonly PaymentService $payments,
) {}
public function placeOrder(int $userId, array $items): array
{
return DB::transaction(function () use ($userId, $items) {
$total = 0;
foreach ($items as $item) {
$product = $this->products->find($item['product_id']);
if ($product->stock < $item['quantity']) {
throw new \RuntimeException("Insufficient stock for {$product->name}");
}
$total += $product->price * $item['quantity'];
$this->products->decrementStock($product->id, $item['quantity']);
}
$order = $this->orders->create([
'user_id' => $userId,
'total' => $total,
'status' => 'pending',
]);
$this->payments->charge($userId, $total, $order->id);
return ['order_id' => $order->id, 'total' => $total];
});
}
}6. Testing the Service in Isolation
tests/Unit/UserServiceTest.php
<?php
namespace Tests\Unit;
use App\Models\User;
use App\Repositories\Contracts\UserRepositoryInterface;
use App\Services\UserService;
use Illuminate\Support\Facades\Mail;
use App\Mail\WelcomeEmail;
use Tests\TestCase;
class UserServiceTest extends TestCase
{
public function test_register_creates_user_and_sends_welcome_email(): void
{
Mail::fake();
$user = User::factory()->make(['id' => 1]);
$repo = $this->mock(UserRepositoryInterface::class);
$repo->shouldReceive('create')->once()->andReturn($user);
$service = app(UserService::class);
$result = $service->register([
'name' => 'Alice',
'email' => 'alice@example.com',
'password' => 'secret123',
]);
$this->assertSame($user, $result);
Mail::assertSent(WelcomeEmail::class, fn ($m) => $m->hasTo('alice@example.com'));
}
public function test_deactivate_returns_false_when_user_not_found(): void
{
$repo = $this->mock(UserRepositoryInterface::class);
$repo->shouldReceive('find')->with(999)->andReturn(null);
$service = app(UserService::class);
$this->assertFalse($service->deactivate(999));
}
}Full Request Lifecycle
Request Flow
HTTP Request
│
▼
Controller ← validates input (FormRequest), returns HTTP response
│
▼
Service ← orchestrates business logic, sends emails, fires events
│
▼
Repository ← reads/writes the database
│
▼
Database (MySQL)💡
Keep services free of HTTP concerns. A service should never reference
request(), response(), or redirect. That makes it reusable from console commands, queue jobs, and API endpoints alike.🎉
Pattern complete! Repository + Service layers together give you clean, testable, and maintainable Laravel applications that scale with your team.
Test your knowledge!
Take the Laravel quiz.