Skip to main content
Back to ScopeForged

ScopeForged Documentation

Technical documentation, guides, and feature references for the ScopeForged client portal.

Architecture Patterns/Service Patterns

Service Patterns Guide

Last Updated: 2026-01-08 Status: Active Audience: Developers

This guide documents service class patterns and best practices for the Client Portal application.


Table of Contents

  1. Overview
  2. When to Use Services
  3. Service Structure
  4. Action Classes
  5. Dependency Injection
  6. Error Handling
  7. Testing Services

Overview

Services encapsulate business logic that doesn't belong in controllers or models. They provide a clean separation of concerns and make code more testable and reusable.

Service vs Controller vs Model

LayerResponsibilityExample
ControllerHTTP handling, validation, responsesParse request, return view
ServiceBusiness logic, orchestrationCalculate invoice total, process payment
ModelData access, relationshipsQuery database, define relations

When to Use Services

Use a Service When

  • Logic involves multiple models
  • Logic is reused across controllers
  • Complex business rules need encapsulation
  • External API calls are involved
  • Logic requires transaction handling

Keep in Controller When

  • Simple CRUD operations
  • Single model operations
  • No business logic beyond validation

Examples

// Simple CRUD - Keep in controller
public function store(StoreClientRequest $request)
{
    $client = Client::create($request->validated());
    return redirect()->route('admin.clients.show', $client);
}

// Complex logic - Use a service
public function store(StoreInvoiceRequest $request, InvoiceService $invoiceService)
{
    $invoice = $invoiceService->createInvoice(
        $request->client,
        $request->validated()
    );
    return redirect()->route('admin.invoices.show', $invoice);
}

Service Structure

Basic Service Class

<?php

namespace App\Services;

use App\Models\Client;
use App\Models\Invoice;
use App\Models\InvoiceItem;
use App\Enums\InvoiceStatus;
use Illuminate\Support\Facades\DB;

class InvoiceService
{
    /**
     * Create a new invoice with line items.
     */
    public function createInvoice(Client $client, array $data): Invoice
    {
        return DB::transaction(function () use ($client, $data) {
            $invoice = Invoice::create([
                'client_id' => $client->id,
                'invoice_number' => $this->generateInvoiceNumber(),
                'status' => InvoiceStatus::Draft,
                'due_date' => $data['due_date'],
                'notes' => $data['notes'] ?? null,
            ]);

            foreach ($data['items'] as $item) {
                $invoice->items()->create([
                    'description' => $item['description'],
                    'quantity' => $item['quantity'],
                    'unit_price' => $item['unit_price'],
                ]);
            }

            $this->recalculateTotals($invoice);

            return $invoice->fresh(['items']);
        });
    }

    /**
     * Recalculate invoice totals from line items.
     */
    public function recalculateTotals(Invoice $invoice): Invoice
    {
        $subtotal = $invoice->items->sum(
            fn (InvoiceItem $item) => $item->quantity * $item->unit_price
        );

        $tax = $subtotal * 0.10; // 10% tax

        $invoice->update([
            'subtotal' => $subtotal,
            'tax' => $tax,
            'total' => $subtotal + $tax,
        ]);

        return $invoice;
    }

    /**
     * Mark invoice as sent and notify client.
     */
    public function sendInvoice(Invoice $invoice): Invoice
    {
        $invoice->update([
            'status' => InvoiceStatus::Sent,
            'sent_at' => now(),
        ]);

        // Dispatch notification job
        SendInvoiceNotification::dispatch($invoice);

        return $invoice;
    }

    /**
     * Mark invoice as paid.
     */
    public function markAsPaid(Invoice $invoice, ?string $transactionId = null): Invoice
    {
        $invoice->update([
            'status' => InvoiceStatus::Paid,
            'paid_at' => now(),
            'transaction_id' => $transactionId,
        ]);

        return $invoice;
    }

    /**
     * Generate unique invoice number.
     */
    protected function generateInvoiceNumber(): string
    {
        $prefix = 'INV-' . date('Y') . '-';
        $lastInvoice = Invoice::where('invoice_number', 'like', $prefix . '%')
            ->orderBy('invoice_number', 'desc')
            ->first();

        if ($lastInvoice) {
            $lastNumber = (int) substr($lastInvoice->invoice_number, -5);
            $newNumber = $lastNumber + 1;
        } else {
            $newNumber = 1;
        }

        return $prefix . str_pad($newNumber, 5, '0', STR_PAD_LEFT);
    }
}

Service with Dependencies

<?php

namespace App\Services;

use App\Models\Project;
use App\Models\ProjectFile;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;

class FileUploadService
{
    public function __construct(
        private readonly ActivityLogService $activityLogger
    ) {}

    /**
     * Upload a file to a project.
     */
    public function uploadToProject(
        Project $project,
        UploadedFile $file,
        array $metadata = []
    ): ProjectFile {
        // Generate unique filename
        $filename = Str::uuid() . '.' . $file->getClientOriginalExtension();
        $path = "projects/{$project->id}/{$filename}";

        // Store file
        Storage::disk('local')->put($path, $file->get());

        // Create database record
        $projectFile = ProjectFile::create([
            'project_id' => $project->id,
            'uploaded_by' => auth()->id(),
            'original_filename' => $file->getClientOriginalName(),
            'stored_filename' => $filename,
            'path' => $path,
            'mime_type' => $file->getMimeType(),
            'size' => $file->getSize(),
            'is_client_visible' => $metadata['is_client_visible'] ?? false,
        ]);

        // Log activity
        $this->activityLogger->log('file_uploaded', $projectFile, [
            'filename' => $file->getClientOriginalName(),
            'size' => $file->getSize(),
        ]);

        return $projectFile;
    }

    /**
     * Delete a project file.
     */
    public function deleteFile(ProjectFile $file): bool
    {
        // Delete from storage
        Storage::disk('local')->delete($file->path);

        // Log before deleting record
        $this->activityLogger->log('file_deleted', $file, [
            'filename' => $file->original_filename,
        ]);

        // Delete database record
        return $file->delete();
    }
}

Action Classes

For single-purpose operations, use Action classes instead of service methods.

Action Class Structure

<?php

namespace App\Actions;

use App\Models\Invoice;
use App\Enums\InvoiceStatus;
use App\Notifications\InvoiceSentNotification;

class SendInvoiceAction
{
    /**
     * Send an invoice to the client.
     */
    public function execute(Invoice $invoice): Invoice
    {
        // Validate invoice can be sent
        if ($invoice->status !== InvoiceStatus::Draft) {
            throw new \InvalidArgumentException(
                'Only draft invoices can be sent.'
            );
        }

        if ($invoice->items->isEmpty()) {
            throw new \InvalidArgumentException(
                'Invoice must have at least one line item.'
            );
        }

        // Update status
        $invoice->update([
            'status' => InvoiceStatus::Sent,
            'sent_at' => now(),
        ]);

        // Send notification
        $invoice->client->notify(new InvoiceSentNotification($invoice));

        return $invoice->fresh();
    }
}

Using Actions

// In controller
public function send(Invoice $invoice, SendInvoiceAction $action)
{
    $this->authorize('send', $invoice);

    try {
        $action->execute($invoice);
        return redirect()->back()->with('success', 'Invoice sent successfully.');
    } catch (\InvalidArgumentException $e) {
        return redirect()->back()->with('error', $e->getMessage());
    }
}

When to Use Actions vs Services

Use ActionUse Service
Single operationMultiple related operations
No shared stateShared dependencies/config
Simple input/outputComplex orchestration
CreateUserActionUserService (CRUD + more)
SendInvoiceActionInvoiceService (create, send, pay, etc.)

Dependency Injection

Constructor Injection (Preferred)

class InvoiceService
{
    public function __construct(
        private readonly PaymentGateway $paymentGateway,
        private readonly ActivityLogService $activityLogger,
        private readonly NotificationService $notifier
    ) {}
}

Method Injection

Use for dependencies needed only in specific methods:

class ReportService
{
    public function generateInvoiceReport(
        Client $client,
        PdfGenerator $pdfGenerator  // Only needed here
    ): string {
        // ...
    }
}

Registering Services

Most services auto-resolve. For interfaces or complex setup:

// app/Providers/AppServiceProvider.php
public function register(): void
{
    $this->app->bind(PaymentGatewayInterface::class, function ($app) {
        return new StripePaymentGateway(
            config('services.stripe.secret')
        );
    });

    // Singleton for expensive initialization
    $this->app->singleton(ReportingService::class, function ($app) {
        return new ReportingService(
            $app->make(CacheRepository::class)
        );
    });
}

Error Handling

Custom Exceptions

<?php

namespace App\Exceptions;

use Exception;

class InvoiceException extends Exception
{
    public static function cannotSendEmpty(): self
    {
        return new self('Cannot send an invoice with no line items.');
    }

    public static function alreadySent(): self
    {
        return new self('This invoice has already been sent.');
    }

    public static function alreadyPaid(): self
    {
        return new self('This invoice has already been paid.');
    }
}

Using Exceptions in Services

class InvoiceService
{
    public function sendInvoice(Invoice $invoice): Invoice
    {
        if ($invoice->items->isEmpty()) {
            throw InvoiceException::cannotSendEmpty();
        }

        if ($invoice->status !== InvoiceStatus::Draft) {
            throw InvoiceException::alreadySent();
        }

        // ... send logic
    }
}

Handling in Controllers

public function send(Invoice $invoice, InvoiceService $service)
{
    try {
        $service->sendInvoice($invoice);
        return redirect()->back()->with('success', 'Invoice sent.');
    } catch (InvoiceException $e) {
        return redirect()->back()->with('error', $e->getMessage());
    }
}

Testing Services

Basic Service Test

<?php

namespace Tests\Unit\Services;

use Tests\TestCase;
use App\Models\Client;
use App\Models\Invoice;
use App\Services\InvoiceService;
use App\Enums\InvoiceStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;

class InvoiceServiceTest extends TestCase
{
    use RefreshDatabase;

    private InvoiceService $service;

    protected function setUp(): void
    {
        parent::setUp();
        $this->service = app(InvoiceService::class);
    }

    public function test_creates_invoice_with_items(): void
    {
        $client = Client::factory()->create();

        $invoice = $this->service->createInvoice($client, [
            'due_date' => now()->addDays(30),
            'items' => [
                ['description' => 'Item 1', 'quantity' => 2, 'unit_price' => 100],
                ['description' => 'Item 2', 'quantity' => 1, 'unit_price' => 50],
            ],
        ]);

        $this->assertInstanceOf(Invoice::class, $invoice);
        $this->assertEquals($client->id, $invoice->client_id);
        $this->assertCount(2, $invoice->items);
        $this->assertEquals(250, $invoice->subtotal);
    }

    public function test_generates_unique_invoice_numbers(): void
    {
        $client = Client::factory()->create();

        $invoice1 = $this->service->createInvoice($client, [
            'due_date' => now()->addDays(30),
            'items' => [['description' => 'Test', 'quantity' => 1, 'unit_price' => 100]],
        ]);

        $invoice2 = $this->service->createInvoice($client, [
            'due_date' => now()->addDays(30),
            'items' => [['description' => 'Test', 'quantity' => 1, 'unit_price' => 100]],
        ]);

        $this->assertNotEquals($invoice1->invoice_number, $invoice2->invoice_number);
    }

    public function test_marks_invoice_as_paid(): void
    {
        $invoice = Invoice::factory()->create([
            'status' => InvoiceStatus::Sent,
        ]);

        $result = $this->service->markAsPaid($invoice, 'txn_123');

        $this->assertEquals(InvoiceStatus::Paid, $result->status);
        $this->assertNotNull($result->paid_at);
        $this->assertEquals('txn_123', $result->transaction_id);
    }
}

Mocking Dependencies

public function test_upload_logs_activity(): void
{
    $activityLogger = $this->mock(ActivityLogService::class);
    $activityLogger->shouldReceive('log')
        ->once()
        ->with('file_uploaded', \Mockery::type(ProjectFile::class), \Mockery::type('array'));

    $service = new FileUploadService($activityLogger);

    $project = Project::factory()->create();
    $file = UploadedFile::fake()->create('document.pdf', 1024);

    $service->uploadToProject($project, $file);
}

Directory Structure

app/
├── Actions/
│   ├── CreateInvoiceAction.php
│   ├── SendInvoiceAction.php
│   └── ProcessPaymentAction.php
├── Services/
│   ├── InvoiceService.php
│   ├── FileUploadService.php
│   ├── ActivityLogService.php
│   └── NotificationService.php
└── Exceptions/
    ├── InvoiceException.php
    └── PaymentException.php

Best Practices

Do

  • Keep services focused on a single domain
  • Use dependency injection
  • Return models or DTOs, not responses
  • Use transactions for multi-step operations
  • Write unit tests for services
  • Use meaningful method names

Don't

  • Access request/session directly in services
  • Return HTTP responses from services
  • Put validation in services (use Form Requests)
  • Create god services (split by domain)
  • Inject services into models