Advanced Laravel

Service Layer Pattern

18 min Lesson 4 of 40

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.

Benefits of Service Layer:
  • 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.

Creating a Basic Service:
// 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();
        });
    }
}
Using Service in Controller:
// 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.

Creating a DTO:
// 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);
    }
}
Using DTOs in Service:
// 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.

Creating Action Classes:
// 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;
    }
}
Using Actions in Service:
// 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.

Creating Command Classes:
// 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);
    }
}
Command Bus Implementation:
// 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;
        });
    }
}
Using Command Bus in Controller:
// 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');
    }
}
When to Use What:
  • 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 stock
  • updateOrderStatus(int $id, string $status) - Update status with validation
  • cancelOrder(int $id) - Cancel order, restore inventory, process refund
  • calculateOrderTotal(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, images
  • UpdateProductDTO - all fields optional
  • Add fromRequest() and toArray() 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 destination
  • ApplyDiscountAction - Apply coupon code to order
  • ProcessPaymentAction - Process payment through gateway
  • SendOrderConfirmationAction - Send confirmation email

Use these actions in an OrderService to complete a checkout process.

Key Takeaways:
  • 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