Service Layer Pattern
Understanding the Service Layer
The Service Layer pattern encapsulates business logic in dedicated service classes, keeping controllers thin and focused on handling HTTP requests. Services coordinate between repositories, perform complex operations, and implement business rules.
- Separates business logic from controllers
- Makes complex operations reusable across controllers
- Improves code testability and maintainability
- Provides a clear API for business operations
- Makes controllers thin and focused on HTTP concerns
- Facilitates code reuse in commands, jobs, and other contexts
Basic Service Class
Let's create a simple service for handling post-related business logic.
// app/Services/PostService.php
namespace App\Services;
use App\Models\Post;
use App\Repositories\Contracts\PostRepositoryInterface;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class PostService
{
protected $postRepository;
public function __construct(PostRepositoryInterface $postRepository)
{
$this->postRepository = $postRepository;
}
public function createPost(array $data): Post
{
// Generate slug from title
$data['slug'] = Str::slug($data['title']);
// Ensure slug is unique
$originalSlug = $data['slug'];
$counter = 1;
while ($this->postRepository->findBySlug($data['slug'])) {
$data['slug'] = $originalSlug . '-' . $counter++;
}
// Set author
$data['author_id'] = auth()->id();
// Create the post
$post = $this->postRepository->create($data);
// Clear cache
Cache::forget('posts.recent');
Cache::forget('posts.featured');
return $post;
}
public function updatePost(int $id, array $data): ?Post
{
$post = $this->postRepository->find($id);
if (!$post) {
return null;
}
// Update slug if title changed
if (isset($data['title']) && $data['title'] !== $post->title) {
$data['slug'] = Str::slug($data['title']);
}
$this->postRepository->update($id, $data);
// Clear cache
Cache::forget("post.{$id}");
Cache::forget('posts.recent');
return $post->fresh();
}
public function publishPost(int $id): bool
{
$post = $this->postRepository->find($id);
if (!$post || $post->status === 'published') {
return false;
}
$this->postRepository->update($id, [
'status' => 'published',
'published_at' => now(),
]);
// Notify subscribers
// This could be dispatched to a queue
// event(new PostPublished($post));
Cache::forget('posts.recent');
return true;
}
public function deletePost(int $id): bool
{
$post = $this->postRepository->find($id);
if (!$post) {
return false;
}
// Check if user has permission
if ($post->author_id !== auth()->id() && !auth()->user()->isAdmin()) {
return false;
}
// Delete associated comments
$post->comments()->delete();
// Delete the post
$this->postRepository->delete($id);
// Clear cache
Cache::forget("post.{$id}");
Cache::forget('posts.recent');
return true;
}
public function getFeaturedPosts(int $limit = 5)
{
return Cache::remember('posts.featured', 3600, function () use ($limit) {
return $this->postRepository
->published()
->where('is_featured', true)
->take($limit)
->get();
});
}
}
// app/Http/Controllers/PostController.php
namespace App\Http\Controllers;
use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest;
use App\Services\PostService;
use Illuminate\Http\Request;
class PostController extends Controller
{
protected $postService;
public function __construct(PostService $postService)
{
$this->postService = $postService;
}
public function store(StorePostRequest $request)
{
$post = $this->postService->createPost($request->validated());
return redirect()
->route('posts.show', $post->slug)
->with('success', 'Post created successfully');
}
public function update(UpdatePostRequest $request, int $id)
{
$post = $this->postService->updatePost($id, $request->validated());
if (!$post) {
return back()->with('error', 'Post not found');
}
return back()->with('success', 'Post updated successfully');
}
public function publish(int $id)
{
$published = $this->postService->publishPost($id);
if (!$published) {
return back()->with('error', 'Unable to publish post');
}
return back()->with('success', 'Post published successfully');
}
public function destroy(int $id)
{
$deleted = $this->postService->deletePost($id);
if (!$deleted) {
return back()->with('error', 'Unable to delete post');
}
return redirect()
->route('posts.index')
->with('success', 'Post deleted successfully');
}
}
Data Transfer Objects (DTOs)
DTOs are simple objects that carry data between processes. They help ensure type safety and provide a clear contract for what data is required.
// app/DTOs/CreatePostDTO.php
namespace App\DTOs;
class CreatePostDTO
{
public function __construct(
public readonly string $title,
public readonly string $content,
public readonly string $status,
public readonly ?string $excerpt = null,
public readonly ?int $categoryId = null,
public readonly array $tags = [],
public readonly bool $isFeatured = false,
) {}
public static function fromRequest(array $data): self
{
return new self(
title: $data['title'],
content: $data['content'],
status: $data['status'] ?? 'draft',
excerpt: $data['excerpt'] ?? null,
categoryId: $data['category_id'] ?? null,
tags: $data['tags'] ?? [],
isFeatured: $data['is_featured'] ?? false,
);
}
public function toArray(): array
{
return [
'title' => $this->title,
'content' => $this->content,
'status' => $this->status,
'excerpt' => $this->excerpt,
'category_id' => $this->categoryId,
'is_featured' => $this->isFeatured,
];
}
}
// app/DTOs/UpdatePostDTO.php
namespace App\DTOs;
class UpdatePostDTO
{
public function __construct(
public readonly ?string $title = null,
public readonly ?string $content = null,
public readonly ?string $status = null,
public readonly ?string $excerpt = null,
public readonly ?int $categoryId = null,
public readonly ?array $tags = null,
public readonly ?bool $isFeatured = null,
) {}
public static function fromRequest(array $data): self
{
return new self(
title: $data['title'] ?? null,
content: $data['content'] ?? null,
status: $data['status'] ?? null,
excerpt: $data['excerpt'] ?? null,
categoryId: $data['category_id'] ?? null,
tags: $data['tags'] ?? null,
isFeatured: $data['is_featured'] ?? null,
);
}
public function toArray(): array
{
return array_filter([
'title' => $this->title,
'content' => $this->content,
'status' => $this->status,
'excerpt' => $this->excerpt,
'category_id' => $this->categoryId,
'is_featured' => $this->isFeatured,
], fn($value) => $value !== null);
}
}
// app/Services/PostService.php (Enhanced)
namespace App\Services;
use App\DTOs\CreatePostDTO;
use App\DTOs\UpdatePostDTO;
use App\Models\Post;
use App\Repositories\Contracts\PostRepositoryInterface;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class PostService
{
protected $postRepository;
public function __construct(PostRepositoryInterface $postRepository)
{
$this->postRepository = $postRepository;
}
public function createPost(CreatePostDTO $dto): Post
{
$data = $dto->toArray();
// Generate unique slug
$data['slug'] = $this->generateUniqueSlug($dto->title);
// Set author
$data['author_id'] = auth()->id();
// Create post
$post = $this->postRepository->create($data);
// Sync tags
if (!empty($dto->tags)) {
$post->tags()->sync($dto->tags);
}
// Clear cache
$this->clearCache($post->id);
return $post;
}
public function updatePost(int $id, UpdatePostDTO $dto): ?Post
{
$post = $this->postRepository->find($id);
if (!$post) {
return null;
}
$data = $dto->toArray();
// Update slug if title changed
if (isset($data['title'])) {
$data['slug'] = $this->generateUniqueSlug($data['title'], $id);
}
$this->postRepository->update($id, $data);
// Sync tags if provided
if ($dto->tags !== null) {
$post->tags()->sync($dto->tags);
}
// Clear cache
$this->clearCache($id);
return $post->fresh();
}
protected function generateUniqueSlug(string $title, ?int $excludeId = null): string
{
$slug = Str::slug($title);
$originalSlug = $slug;
$counter = 1;
while ($this->slugExists($slug, $excludeId)) {
$slug = $originalSlug . '-' . $counter++;
}
return $slug;
}
protected function slugExists(string $slug, ?int $excludeId = null): bool
{
$query = Post::where('slug', $slug);
if ($excludeId) {
$query->where('id', '!=', $excludeId);
}
return $query->exists();
}
protected function clearCache(int $postId): void
{
Cache::forget("post.{$postId}");
Cache::forget('posts.recent');
Cache::forget('posts.featured');
}
}
Action Classes
Action classes are single-responsibility classes that perform one specific action. They're perfect for complex operations that don't fit cleanly into services.
// app/Actions/GeneratePostExcerptAction.php
namespace App\Actions;
use Illuminate\Support\Str;
class GeneratePostExcerptAction
{
public function execute(string $content, int $length = 200): string
{
// Strip HTML tags
$text = strip_tags($content);
// Remove extra whitespace
$text = preg_replace('/\s+/', ' ', $text);
// Trim to length
if (strlen($text) > $length) {
$text = substr($text, 0, $length);
// Cut at last word boundary
$lastSpace = strrpos($text, ' ');
if ($lastSpace !== false) {
$text = substr($text, 0, $lastSpace);
}
$text .= '...';
}
return trim($text);
}
}
// app/Actions/CalculateReadingTimeAction.php
namespace App\Actions;
class CalculateReadingTimeAction
{
protected const WORDS_PER_MINUTE = 200;
public function execute(string $content): int
{
$wordCount = str_word_count(strip_tags($content));
$minutes = ceil($wordCount / self::WORDS_PER_MINUTE);
return max(1, $minutes); // Minimum 1 minute
}
}
// app/Actions/PublishPostAction.php
namespace App\Actions;
use App\Events\PostPublished;
use App\Models\Post;
use Illuminate\Support\Facades\Cache;
class PublishPostAction
{
public function execute(Post $post): bool
{
if ($post->status === 'published') {
return false;
}
$post->update([
'status' => 'published',
'published_at' => now(),
]);
// Dispatch event
event(new PostPublished($post));
// Clear cache
Cache::forget('posts.recent');
Cache::forget('posts.published');
return true;
}
}
// app/Services/PostService.php (with Actions)
namespace App\Services;
use App\Actions\CalculateReadingTimeAction;
use App\Actions\GeneratePostExcerptAction;
use App\Actions\PublishPostAction;
use App\DTOs\CreatePostDTO;
use App\Models\Post;
use App\Repositories\Contracts\PostRepositoryInterface;
class PostService
{
public function __construct(
protected PostRepositoryInterface $postRepository,
protected GeneratePostExcerptAction $generateExcerpt,
protected CalculateReadingTimeAction $calculateReadingTime,
protected PublishPostAction $publishPostAction
) {}
public function createPost(CreatePostDTO $dto): Post
{
$data = $dto->toArray();
// Auto-generate excerpt if not provided
if (empty($data['excerpt'])) {
$data['excerpt'] = $this->generateExcerpt->execute($dto->content);
}
// Calculate reading time
$data['reading_time'] = $this->calculateReadingTime->execute($dto->content);
// Generate unique slug
$data['slug'] = $this->generateUniqueSlug($dto->title);
$data['author_id'] = auth()->id();
return $this->postRepository->create($data);
}
public function publishPost(int $id): bool
{
$post = $this->postRepository->find($id);
if (!$post) {
return false;
}
return $this->publishPostAction->execute($post);
}
// ... other methods
}
Command Pattern for Complex Operations
The Command pattern encapsulates requests as objects, making it easy to queue, log, or undo operations.
// app/Commands/CreatePostCommand.php
namespace App\Commands;
use App\DTOs\CreatePostDTO;
use App\Models\Post;
class CreatePostCommand
{
public function __construct(
public readonly CreatePostDTO $dto
) {}
}
// app/Commands/Handlers/CreatePostHandler.php
namespace App\Commands\Handlers;
use App\Commands\CreatePostCommand;
use App\Models\Post;
use App\Repositories\Contracts\PostRepositoryInterface;
use App\Services\PostService;
class CreatePostHandler
{
public function __construct(
protected PostService $postService
) {}
public function handle(CreatePostCommand $command): Post
{
return $this->postService->createPost($command->dto);
}
}
// app/Services/CommandBus.php
namespace App\Services;
use Illuminate\Container\Container;
class CommandBus
{
protected $container;
protected $handlers = [];
public function __construct(Container $container)
{
$this->container = $container;
}
public function register(string $command, string $handler): void
{
$this->handlers[$command] = $handler;
}
public function dispatch($command)
{
$commandClass = get_class($command);
if (!isset($this->handlers[$commandClass])) {
throw new \Exception("No handler registered for {$commandClass}");
}
$handler = $this->container->make($this->handlers[$commandClass]);
return $handler->handle($command);
}
}
// app/Providers/CommandBusServiceProvider.php
namespace App\Providers;
use App\Commands\CreatePostCommand;
use App\Commands\Handlers\CreatePostHandler;
use App\Services\CommandBus;
use Illuminate\Support\ServiceProvider;
class CommandBusServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(CommandBus::class, function ($app) {
$bus = new CommandBus($app);
// Register command handlers
$bus->register(CreatePostCommand::class, CreatePostHandler::class);
return $bus;
});
}
}
// app/Http/Controllers/PostController.php
namespace App\Http\Controllers;
use App\Commands\CreatePostCommand;
use App\DTOs\CreatePostDTO;
use App\Http\Requests\StorePostRequest;
use App\Services\CommandBus;
class PostController extends Controller
{
protected $commandBus;
public function __construct(CommandBus $commandBus)
{
$this->commandBus = $commandBus;
}
public function store(StorePostRequest $request)
{
$dto = CreatePostDTO::fromRequest($request->validated());
$command = new CreatePostCommand($dto);
$post = $this->commandBus->dispatch($command);
return redirect()
->route('posts.show', $post->slug)
->with('success', 'Post created successfully');
}
}
- Services: Coordinate between repositories, handle business logic
- DTOs: Transfer data between layers, ensure type safety
- Actions: Single-responsibility operations, reusable logic
- Commands: Complex operations that need queuing, logging, or undo
Exercise 1: Create an Order Service
Build an OrderService class that handles:
createOrder(CreateOrderDTO $dto)- Create order, calculate totals, validate stockupdateOrderStatus(int $id, string $status)- Update status with validationcancelOrder(int $id)- Cancel order, restore inventory, process refundcalculateOrderTotal(Order $order)- Calculate total including tax and shipping
Exercise 2: Implement DTOs
Create DTOs for a product management system:
CreateProductDTO- name, description, price, category_id, stock, imagesUpdateProductDTO- all fields optional- Add
fromRequest()andtoArray()methods - Use type safety (readonly properties, type hints)
Exercise 3: Build Action Classes
Create action classes for an e-commerce system:
CalculateShippingCostAction- Calculate shipping based on weight and destinationApplyDiscountAction- Apply coupon code to orderProcessPaymentAction- Process payment through gatewaySendOrderConfirmationAction- Send confirmation email
Use these actions in an OrderService to complete a checkout process.
- Service layer keeps controllers thin and focused
- DTOs provide type safety and clear data contracts
- Action classes implement single-responsibility principle
- Command pattern makes operations queueable and testable
- Choose the right pattern based on complexity and requirements
- Always prioritize code readability and maintainability