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 } }