⬡ Laravel 11 · PHP 8.3 · Producción

AgenciaFlow — Arquitectura Laravel

Patrones de producción aplicados a un SaaS B2B multi-tenant para agencias de marketing digital. Controllers delgados, Actions, Services, Colas y API Resources con paginación estándar.

MySQL 8 + Redis
Sanctum Auth
Horizon Queues
Multi-tenant Scoped
📁
Estructura de Proyecto
Layout convencional de Laravel con capas bien definidas para AgenciaFlow
agenciaflow/ tree
Directorio raíz
app/
├── Actions/                # Casos de uso de propósito único
│   ├── Campaigns/
│   │   ├── CreateCampaignAction.php
│   │   ├── ArchiveCampaignAction.php
│   │   └── DuplicateCampaignAction.php
│   └── Reports/
│       └── GeneratePerformanceReportAction.php
├── Console/
├── Data/                    # DTOs (objetos de transferencia de datos)
│   ├── CreateCampaignData.php
│   └── CreateReportData.php
├── Events/
│   └── CampaignCreated.php
├── Http/
│   ├── Controllers/Api/     # Controladores DELGADOS — solo orquestación
│   │   ├── CampaignController.php
│   │   └── PerformanceReportController.php
│   ├── Middleware/
│   │   └── EnsureAgencyMembership.php
│   ├── Requests/            # Validación + auth de policy
│   │   └── StoreCampaignRequest.php
│   └── Resources/          # Serialización de respuestas API
│       ├── CampaignResource.php
│       └── PerformanceReportResource.php
├── Jobs/
│   └── GeneratePerformanceReportJob.php
├── Models/
│   ├── Agency.php
│   ├── Campaign.php
│   ├── Client.php
│   └── PerformanceReport.php
├── Policies/
│   └── CampaignPolicy.php
├── Repositories/
│   ├── CampaignRepository.php
│   └── EloquentCampaignRepository.php
├── Services/              # Servicios de dominio que coordinan
│   └── CampaignService.php
└── Support/
    └── Enums/
        └── CampaignStatus.php
database/
├── migrations/
├── factories/
└── seeders/
routes/
├── api.php
└── web.php
Flujo de Capas
Cada request pasa por capas claras con responsabilidades únicas
HTTP RequestRoute + Middleware
FormRequestValidar + Autorizar
ControllerSolo orquestación
ActionCaso de uso único
Model / RepoPersistencia
CORRECTO
El controller recibe el request validado y delega a la Action. Devuelve el Resource ya serializado. Sin lógica de negocio.
EVITAR
Poner queries, cálculos, envío de emails o lógica condicional directamente en el controller. Controladores gordos.
🗄️
Modelos Eloquent
Typed models con casts, scopes, relaciones y SoftDeletes
app/Models/Campaign.php PHP
Eloquent Model
<?php

declare(strict_types=1);

namespace App\Models;

use App\Support\Enums\CampaignStatus;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;

final class Campaign extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = [
        'client_id', 'name', 'platform',
        'status', 'budget_cents', 'starts_at', 'ends_at',
    ];

    /** @return array<string, string> */
    protected function casts(): array
    {
        return [
            'status'     => CampaignStatus::class,   // PHP 8.1 Enum
            'starts_at'  => 'datetime',
            'ends_at'    => 'datetime',
            'budget_cents' => 'integer',
        ];
    }

    // ── Relaciones ────────────────────────────────────────────

    public function client(): BelongsTo
    {
        return $this->belongsTo(Client::class);
    }

    public function reports(): HasMany
    {
        return $this->hasMany(PerformanceReport::class);
    }

    // ── Scopes reutilizables ──────────────────────────────────

    public function scopeActive(Builder $query): Builder
    {
        return $query->where('status', CampaignStatus::Active);
    }

    public function scopeForAgency(Builder $query, int $agencyId): Builder
    {
        return $query->whereHas('client', fn (Builder $q) =>
            $q->where('agency_id', $agencyId)
        );
    }

    public function scopeOwnedBy(Builder $query, int $clientId): Builder
    {
        return $query->where('client_id', $clientId);
    }

    // ── Helpers ────────────────────────────────────────────────

    public function budgetInEur(): float
    {
        return $this->budget_cents / 100;
    }

    public function isActive(): bool
    {
        return $this->status === CampaignStatus::Active;
    }
}
app/Support/Enums/CampaignStatus.php PHP
PHP 8.1 Enum
<?php

declare(strict_types=1);

namespace App\Support\Enums;

enum CampaignStatus: string
{
    case Draft    = 'draft';
    case Active   = 'active';
    case Paused   = 'paused';
    case Archived = 'archived';
    case Ended    = 'ended';

    public function label(): string
    {
        return match($this) {
            CampaignStatus::Draft    => 'Borrador',
            CampaignStatus::Active   => 'Activa',
            CampaignStatus::Paused   => 'Pausada',
            CampaignStatus::Archived => 'Archivada',
            CampaignStatus::Ended    => 'Finalizada',
        };
    }

    public function canTransitionTo(CampaignStatus $next): bool
    {
        return match($this) {
            self::Draft    => in_array($next, [self::Active]),
            self::Active   => in_array($next, [self::Paused, self::Ended, self::Archived]),
            self::Paused   => in_array($next, [self::Active, self::Archived]),
            default        => false,
        };
    }
}
Actions — Casos de Uso Únicos
Una Action = un caso de uso. Inyectables, testeables, sin efectos secundarios en el constructor.
app/Actions/Campaigns/CreateCampaignAction.php PHP
Action
<?php

declare(strict_types=1);

namespace App\Actions\Campaigns;

use App\Data\CreateCampaignData;
use App\Events\CampaignCreated;
use App\Models\Campaign;
use App\Repositories\CampaignRepository;

final class CreateCampaignAction
{
    public function __construct(
        private readonly CampaignRepository $campaigns,
    ) {}

    public function handle(CreateCampaignData $data): Campaign
    {
        $campaign = $this->campaigns->create($data);

        CampaignCreated::dispatch($campaign);

        return $campaign;
    }
}


// ── Controller que usa esta Action ────────────────────────────

final class CampaignController extends Controller
{
    public function __construct(
        private readonly CreateCampaignAction $createCampaign,
    ) {}

    public function store(StoreCampaignRequest $request, Client $client): JsonResponse
    {
        $campaign = $this->createCampaign->handle(
            $request->toDto($client)
        );

        return response()->json([
            'success' => true,
            'data'    => CampaignResource::make($campaign),
            'error'   => null,
            'meta'    => null,
        ], 201);
    }

    public function index(Client $client): JsonResponse
    {
        $campaigns = Campaign::query()
            ->ownedBy($client->id)
            ->active()
            ->with(['client'])
            ->latest()
            ->paginate(25);

        return response()->json([
            'success' => true,
            'data'    => CampaignResource::collection($campaigns->items()),
            'error'   => null,
            'meta'    => [
                'page'      => $campaigns->currentPage(),
                'per_page'  => $campaigns->perPage(),
                'total'     => $campaigns->total(),
                'last_page' => $campaigns->lastPage(),
            ],
        ]);
    }
}
Form Requests + DTOs
Validación desacoplada del controller, transformada en un DTO tipado
app/Http/Requests/StoreCampaignRequest.php PHP
FormRequest
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Data\CreateCampaignData;
use App\Models\Campaign;
use App\Models\Client;
use App\Support\Enums\CampaignStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Enum;

final class StoreCampaignRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()?->can('create', [Campaign::class, $this->route('client')])
            ?? false;
    }

    /** @return array<string, mixed> */
    public function rules(): array
    {
        return [
            'name'         => ['required', 'string', 'max:150'],
            'platform'     => ['required', 'string', 'in:google,meta,tiktok,linkedin'],
            'budget_cents' => ['required', 'integer', 'min:100'],
            'starts_at'    => ['required', 'date', 'after:today'],
            'ends_at'      => ['nullable', 'date', 'after:starts_at'],
        ];
    }

    public function toDto(Client $client): CreateCampaignData
    {
        return new CreateCampaignData(
            clientId:    $client->id,
            name:        $this->string('name')->trim()->value(),
            platform:    $this->string('platform')->value(),
            budgetCents: (int) $this->validated('budget_cents'),
            startsAt:    $this->date('starts_at'),
            endsAt:      $this->date('ends_at'),
            status:      CampaignStatus::Draft,
        );
    }
}


// ── DTO tipado ─────────────────────────────────────────────────

final readonly class CreateCampaignData
{
    public function __construct(
        public int $clientId,
        public string $name,
        public string $platform,
        public int $budgetCents,
        public ?\DateTimeInterface $startsAt,
        public ?\DateTimeInterface $endsAt,
        public CampaignStatus $status = CampaignStatus::Draft,
    ) {}
}
🛣️
Rutas Multi-tenant con Scoped Bindings
Previene acceso cruzado entre agencias usando scoped bindings anidados
routes/api.php PHP
API Routes
<?php

use App\Http\Controllers\Api\CampaignController;
use App\Http\Controllers\Api\PerformanceReportController;
use Illuminate\Support\Facades\Route;

/*
 * Todas las rutas de API requieren Sanctum + pertenencia a la agencia.
 * scopeBindings() garantiza que {campaign} pertenezca a {client},
 * y {client} pertenezca a la {agency} del token autenticado.
 */

Route::middleware(['auth:sanctum', 'agency.member'])
    ->prefix('v1/agencies/{agency}')
    ->group(function (): void {

        // Clientes de la agencia
        Route::apiResource('clients', ClientController::class);

        // Campañas — scoped a client para evitar cross-tenant
        Route::scopeBindings()->group(function (): void {

            Route::apiResource(
                'clients.campaigns',
                CampaignController::class
            );

            // Reportes de performance — async via cola
            Route::post(
                'clients/{client}/campaigns/{campaign}/reports',
                [PerformanceReportController::class, 'store']
            )->name('campaigns.reports.store');

            Route::get(
                'clients/{client}/campaigns/{campaign}/reports/{report}',
                [PerformanceReportController::class, 'show']
            )->name('campaigns.reports.show');
        });
    });
ℹ️
scopeBindings() fuerza a que {campaign} se resuelva verificando que pertenezca al {client} del URL. Sin esto, un token malicioso podría acceder a campañas de otra agencia pasando un ID arbitrario.
📦
API Resources con Paginación Estándar
Respuestas consistentes: { success, data, error, meta }
app/Http/Resources/CampaignResource.php PHP
API Resource
<?php

declare(strict_types=1);

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

/** @mixin \App\Models\Campaign */
final class CampaignResource extends JsonResource
{
    /** @return array<string, mixed> */
    public function toArray($request): array
    {
        return [
            'id'          => $this->id,
            'name'        => $this->name,
            'platform'    => $this->platform,
            'status'      => [
                'value' => $this->status->value,
                'label' => $this->status->label(),
            ],
            'budget'      => [
                'cents'    => $this->budget_cents,
                'formatted' => number_format($this->budgetInEur(), 2) . ' €',
            ],
            'starts_at'   => $this->starts_at?->toISOString(),
            'ends_at'     => $this->ends_at?->toISOString(),
            'client'      => ClientResource::make($this->whenLoaded('client')),
            'created_at'  => $this->created_at?->toISOString(),
        ];
    }
}


/* Ejemplo de respuesta JSON resultante:
{
  "success": true,
  "data": [
    {
      "id": 42,
      "name": "Black Friday Meta Ads 2024",
      "platform": "meta",
      "status": { "value": "active", "label": "Activa" },
      "budget": { "cents": 500000, "formatted": "5.000,00 €" },
      "starts_at": "2024-11-20T00:00:00.000Z",
      "ends_at": "2024-11-30T23:59:59.000Z",
      "client": { "id": 7, "name": "Tienda MarcaX" },
      "created_at": "2024-10-15T09:30:00.000Z"
    }
  ],
  "error": null,
  "meta": { "page": 1, "per_page": 25, "total": 48, "last_page": 2 }
}
*/
⚙️
Jobs Asíncronos para Reportes Pesados
El procesamiento de datos de Google Ads / Meta Ads va a cola, no bloquea el request
app/Jobs/GeneratePerformanceReportJob.php PHP
Queued Job
<?php

declare(strict_types=1);

namespace App\Jobs;

use App\Models\Campaign;
use App\Models\PerformanceReport;
use App\Services\AdsDataFetcher;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

final class GeneratePerformanceReportJob implements ShouldQueue
{
    use Queueable, InteractsWithQueue, SerializesModels;

    /** Reintentos con backoff exponencial */
    public int $tries = 3;
    public int $backoff = 60; // segundos
    public int $timeout = 120;

    public function __construct(
        public readonly Campaign $campaign,
        public readonly string $period, // 'last_7d' | 'last_30d' | 'custom'
        public readonly ?string $reportId = null,
    ) {
        $this->onQueue('reports');
    }

    public function handle(AdsDataFetcher $fetcher): void
    {
        $report = PerformanceReport::findOrFail($this->reportId);
        $report->update(['status' => 'processing']);

        try {
            $metrics = $fetcher->fetch($this->campaign, $this->period);

            $report->update([
                'status'       => 'completed',
                'impressions'  => $metrics->impressions,
                'clicks'       => $metrics->clicks,
                'conversions'  => $metrics->conversions,
                'spend_cents'  => $metrics->spendCents,
                'completed_at' => now(),
            ]);

            // Invalidar caché de dashboard de la agencia
            cache()->tags(["agency:{$this->campaign->client->agency_id}"])->flush();

        } catch (\Throwable $e) {
            $report->update(['status' => 'failed']);
            throw $e; // permite reintentos de la cola
        }
    }
}
El controller solo hace dispatch(new GeneratePerformanceReportJob(...)) y devuelve 202 Accepted. El worker procesa en background. Al terminar, invalida el caché de la agencia con tags.
🔵
Estrategia de Caché con Tags
Dashboards costosos en Redis, invalidación por tags de agencia
app/Services/CampaignService.php PHP
Service + Cache
<?php

declare(strict_types=1);

namespace App\Services;

use App\Models\Agency;
use Illuminate\Support\Facades\Cache;

final class CampaignService
{
    public function dashboardMetrics(Agency $agency): array
    {
        // Tags permiten invalidar todo lo de la agencia de un golpe
        return Cache::tags([
            "agency:{$agency->id}",
            'dashboard',
        ])->remember(
            key:     "agency:{$agency->id}:dashboard",
            ttl:     now()->addMinutes(15),
            callback: fn () => $this->computeDashboard($agency),
        );
    }

    private function computeDashboard(Agency $agency): array
    {
        return [
            'total_campaigns'   => $agency->campaigns()->active()->count(),
            'total_spend_cents' => $agency->campaigns()->sum('budget_cents'),
            'top_platform'      => $agency->campaigns()
                ->selectRaw('platform, COUNT(*) as cnt')
                ->groupBy('platform')
                ->orderByDesc('cnt')
                ->value('platform'),
            'clients_count'     => $agency->clients()->count(),
        ];
    }
}
📋
Migraciones Limpias
Clases anónimas, foreign keys con cascade, índices donde procede
database/migrations/2024_01_15_000002_create_campaigns_table.php PHP
Migration
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('campaigns', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('client_id')
                  ->constrained()
                  ->cascadeOnDelete();

            $table->string('name', 150);
            $table->string('platform', 32)->index();
            $table->string('status', 32)->default('draft')->index();
            $table->unsignedInteger('budget_cents');

            $table->date('starts_at')->index();
            $table->date('ends_at')->nullable();

            $table->timestamps();
            $table->softDeletes(); // Campañas son recuperables

            // Índice compuesto para el scope más común
            $table->index(['client_id', 'status'], 'idx_campaigns_client_status');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('campaigns');
    }
};
📌 Reglas de arquitectura
  • Controllers sin lógica de negocio
  • 1 Action = 1 caso de uso
  • DTOs readonly para mover datos
  • Eager loading obligatorio (sin N+1)
  • scopeBindings() para multi-tenant
  • IO pesado siempre a cola
  • Caché con tags e invalidación
  • Sin lógica en vistas o Blade
  • Sin queries en el constructor
  • Sin facades en Actions/Services
⚡ Respuesta API estándar
{
  "success": true | false,
  "data":    // resource | collection | null
  "error":   // null | { code, message }
  "meta":    // null | paginación | extra
  // Paginación:
  "meta": {
    "page":      1,
    "per_page":  25,
    "total":     142,
    "last_page": 6
  }
}