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
- Overview
- When to Use Services
- Service Structure
- Action Classes
- Dependency Injection
- Error Handling
- 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
| Layer | Responsibility | Example |
|---|---|---|
| Controller | HTTP handling, validation, responses | Parse request, return view |
| Service | Business logic, orchestration | Calculate invoice total, process payment |
| Model | Data access, relationships | Query 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 Action | Use Service |
|---|---|
| Single operation | Multiple related operations |
| No shared state | Shared dependencies/config |
| Simple input/output | Complex orchestration |
CreateUserAction | UserService (CRUD + more) |
SendInvoiceAction | InvoiceService (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