Skip to content

Service Pattern

Services encapsulate business logic and coordinate between multiple models or external systems.

When to Use Services

Use services for:

  • Complex business logic involving multiple models
  • External API integrations
  • Operations requiring multiple steps
  • Reusable functionality across controllers

Don't use services for:

  • Simple CRUD operations (use Actions)
  • Single-model operations
  • View logic (use View Models)

Basic Service

php
<?php

namespace Mod\Blog\Services;

use Mod\Blog\Models\Post;
use Mod\Tenant\Models\User;

class PostPublishingService
{
    public function publish(Post $post, User $user): Post
    {
        // Verify post is ready
        $this->validateReadyForPublish($post);

        // Update post
        $post->update([
            'status' => 'published',
            'published_at' => now(),
            'published_by' => $user->id,
        ]);

        // Generate SEO metadata
        $this->generateSeoMetadata($post);

        // Notify subscribers
        $this->notifySubscribers($post);

        // Update search index
        $post->searchable();

        return $post->fresh();
    }

    protected function validateReadyForPublish(Post $post): void
    {
        if (empty($post->title)) {
            throw new ValidationException('Post must have a title');
        }

        if (empty($post->content)) {
            throw new ValidationException('Post must have content');
        }

        if (!$post->featured_image) {
            throw new ValidationException('Post must have a featured image');
        }
    }

    protected function generateSeoMetadata(Post $post): void
    {
        if (empty($post->meta_description)) {
            $post->meta_description = str($post->content)
                ->stripTags()
                ->limit(160);
        }

        if (empty($post->og_image)) {
            GenerateOgImageJob::dispatch($post);
        }

        $post->save();
    }

    protected function notifySubscribers(Post $post): void
    {
        NotifySubscribersJob::dispatch($post);
    }
}

Usage:

php
$service = app(PostPublishingService::class);
$publishedPost = $service->publish($post, auth()->user());

Service with Constructor Injection

php
<?php

namespace Mod\Analytics\Services;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;

class AnalyticsService
{
    public function __construct(
        protected string $apiKey,
        protected string $apiUrl
    ) {}

    public function trackPageView(string $url, array $meta = []): void
    {
        Http::post("{$this->apiUrl}/events", [
            'api_key' => $this->apiKey,
            'event' => 'pageview',
            'url' => $url,
            'meta' => $meta,
        ]);
    }

    public function getPageViews(string $url, int $days = 30): int
    {
        return Cache::remember(
            "analytics.pageviews.{$url}.{$days}",
            now()->addHour(),
            fn () => Http::get("{$this->apiUrl}/stats", [
                'api_key' => $this->apiKey,
                'url' => $url,
                'days' => $days,
            ])->json('views')
        );
    }
}

Service Provider:

php
$this->app->singleton(AnalyticsService::class, function () {
    return new AnalyticsService(
        apiKey: config('analytics.api_key'),
        apiUrl: config('analytics.api_url')
    );
});

Service Contracts

Define interfaces for flexibility:

php
<?php

namespace Core\Service\Contracts;

interface PaymentGatewayService
{
    public function charge(int $amount, string $currency, array $meta = []): PaymentResult;
    public function refund(string $transactionId, ?int $amount = null): RefundResult;
    public function getTransaction(string $transactionId): Transaction;
}

Implementation:

php
<?php

namespace Mod\Stripe\Services;

use Core\Service\Contracts\PaymentGatewayService;

class StripePaymentService implements PaymentGatewayService
{
    public function __construct(
        protected \Stripe\StripeClient $client
    ) {}

    public function charge(int $amount, string $currency, array $meta = []): PaymentResult
    {
        $intent = $this->client->paymentIntents->create([
            'amount' => $amount,
            'currency' => $currency,
            'metadata' => $meta,
        ]);

        return new PaymentResult(
            success: $intent->status === 'succeeded',
            transactionId: $intent->id,
            amount: $intent->amount,
            currency: $intent->currency
        );
    }

    // ... other methods
}

Service with Dependencies

php
<?php

namespace Mod\Shop\Services;

use Mod\Shop\Models\Order;
use Core\Service\Contracts\PaymentGatewayService;
use Mod\Email\Services\EmailService;

class OrderProcessingService
{
    public function __construct(
        protected PaymentGatewayService $payment,
        protected EmailService $email,
        protected InventoryService $inventory
    ) {}

    public function process(Order $order): ProcessingResult
    {
        // Validate inventory
        if (!$this->inventory->available($order->items)) {
            return ProcessingResult::failed('Insufficient inventory');
        }

        // Reserve inventory
        $this->inventory->reserve($order->items);

        try {
            // Charge payment
            $payment = $this->payment->charge(
                amount: $order->total,
                currency: $order->currency,
                meta: ['order_id' => $order->id]
            );

            if (!$payment->success) {
                $this->inventory->release($order->items);
                return ProcessingResult::failed('Payment failed');
            }

            // Update order
            $order->update([
                'status' => 'paid',
                'transaction_id' => $payment->transactionId,
                'paid_at' => now(),
            ]);

            // Send confirmation
            $this->email->send(
                to: $order->customer->email,
                template: 'order-confirmation',
                data: compact('order', 'payment')
            );

            return ProcessingResult::success($order);

        } catch (\Exception $e) {
            $this->inventory->release($order->items);
            throw $e;
        }
    }
}

Service with Events

php
<?php

namespace Mod\Blog\Services;

use Mod\Blog\Events\PostPublished;
use Mod\Blog\Events\PostScheduled;

class PostSchedulingService
{
    public function schedulePost(Post $post, Carbon $publishAt): void
    {
        $post->update([
            'status' => 'scheduled',
            'publish_at' => $publishAt,
        ]);

        // Dispatch event
        event(new PostScheduled($post, $publishAt));

        // Queue job to publish
        PublishScheduledPostJob::dispatch($post)
            ->delay($publishAt);
    }

    public function publishScheduledPost(Post $post): void
    {
        if ($post->status !== 'scheduled') {
            throw new InvalidStateException('Post is not scheduled');
        }

        $post->update([
            'status' => 'published',
            'published_at' => now(),
        ]);

        event(new PostPublished($post));
    }
}

Testing Services

php
<?php

namespace Tests\Unit\Services;

use Tests\TestCase;
use Mod\Blog\Services\PostPublishingService;
use Mod\Blog\Models\Post;

class PostPublishingServiceTest extends TestCase
{
    public function test_publishes_post(): void
    {
        $service = app(PostPublishingService::class);
        $user = User::factory()->create();
        $post = Post::factory()->create(['status' => 'draft']);

        $result = $service->publish($post, $user);

        $this->assertEquals('published', $result->status);
        $this->assertNotNull($result->published_at);
        $this->assertEquals($user->id, $result->published_by);
    }

    public function test_validates_post_before_publishing(): void
    {
        $service = app(PostPublishingService::class);
        $user = User::factory()->create();
        $post = Post::factory()->create([
            'title' => '',
            'status' => 'draft',
        ]);

        $this->expectException(ValidationException::class);

        $service->publish($post, $user);
    }

    public function test_generates_seo_metadata(): void
    {
        $service = app(PostPublishingService::class);
        $user = User::factory()->create();
        $post = Post::factory()->create([
            'content' => 'Long content here...',
            'meta_description' => null,
        ]);

        $result = $service->publish($post, $user);

        $this->assertNotNull($result->meta_description);
    }
}

Best Practices

1. Single Responsibility

php
// ✅ Good - focused service
class EmailVerificationService
{
    public function sendVerificationEmail(User $user): void {}
    public function verify(string $token): bool {}
    public function resend(User $user): void {}
}

// ❌ Bad - too broad
class UserService
{
    public function create() {}
    public function sendEmail() {}
    public function processPayment() {}
    public function generateReport() {}
}

2. Dependency Injection

php
// ✅ Good - injected dependencies
public function __construct(
    protected EmailService $email,
    protected PaymentGateway $payment
) {}

// ❌ Bad - hard-coded dependencies
public function __construct()
{
    $this->email = new EmailService();
    $this->payment = new StripeGateway();
}

3. Return Types

php
// ✅ Good - explicit return type
public function process(Order $order): ProcessingResult
{
    return new ProcessingResult(...);
}

// ❌ Bad - no return type
public function process(Order $order)
{
    return [...];
}

4. Error Handling

php
// ✅ Good - handle errors gracefully
public function process(Order $order): ProcessingResult
{
    try {
        $result = $this->payment->charge($order->total);

        return ProcessingResult::success($result);
    } catch (PaymentException $e) {
        Log::error('Payment failed', ['order' => $order->id, 'error' => $e->getMessage()]);

        return ProcessingResult::failed($e->getMessage());
    }
}

Learn More

Released under the EUPL-1.2 License.