Skip to content

Arquitectura

HUMAE backend es Laravel 12 con arquitectura por capas explícita. El principio rector es: controllers delgados, services gordos.

Stack

ComponenteVersiónRazón
Laravel12.xFramework base
PHP8.3+Typed properties, enums, readonly
MySQL8.0+InnoDB + generated columns
Sanctum4.xAuth SPA + tokens
Spatie Permission7.xRoles + permisos
Spatie Activitylog5.xAuditoría (instalado, no activo aún)
Stripe PHP20.xPasarela de pagos
Intervention Image3.xResize de avatares (GD/Imagick)
DomPDF3.xGeneración de PDF
SMTP local (Postfix)Envío de correos (Laravel smtp driver a 127.0.0.1:25)
Pest3.xTesting
Larastan3.x (level 8)Análisis estático
Pint1.xLint/formato
Scribe5.xAPI docs

Tests usan SQLite in-memory vía phpunit.xml (sin tocar MySQL).

Capas (flujo de una request)

┌────────────────────────────────────────────┐
│  ROUTE (routes/api.php)                    │
│  Agrupa por prefijo + middleware de rol    │
└───────────────────┬────────────────────────┘

┌────────────────────────────────────────────┐
│  MIDDLEWARE                                │
│  auth:sanctum, role:*, throttle:*          │
└───────────────────┬────────────────────────┘

┌────────────────────────────────────────────┐
│  FORM REQUEST                              │
│  Validación + autorización (authorize())   │
└───────────────────┬────────────────────────┘

┌────────────────────────────────────────────┐
│  CONTROLLER                                │
│  ≤ 20 líneas por método                    │
│  Valida → delega a Service → transforma    │
└───────────────────┬────────────────────────┘

┌────────────────────────────────────────────┐
│  SERVICE                                   │
│  Lógica transaccional                      │
│  State machines                            │
│  Dispara notifications                     │
└───────────────────┬────────────────────────┘

┌────────────────────────────────────────────┐
│  MODEL (Eloquent)                          │
│  Relaciones, casts, scopes                 │
└───────────────────┬────────────────────────┘

┌────────────────────────────────────────────┐
│  API RESOURCE                              │
│  Transforma modelo → JSON (envelope)       │
└────────────────────────────────────────────┘

Principios

1. Controllers delgados

Regla: máximo ~20 líneas por método de controller. Si crece, mueve lógica a un service.

php
// ❌ MAL — lógica mezclada
public function store(Request $request)
{
    $request->validate([...]);
    $user = User::create($request->all());
    $payment = Payment::create([...]);
    $stripe = new StripeClient(config('services.stripe.secret'));
    $session = $stripe->checkout->sessions->create([...]);
    // ... 80 líneas más
}

// ✅ BIEN — delegación
public function store(CreateCheckoutRequest $request, MembershipService $service)
{
    $plan = MembershipPlan::where('code', 'candidate_6m')->firstOrFail();
    $result = $service->createCheckoutSession($request->user(), $plan);
    return $this->success('Sesión creada', $result, status: 201);
}

2. Services con responsabilidad clara

Un service = un bounded context. Cada uno tiene dependencias inyectadas por constructor.

php
final class MembershipService
{
    public function __construct(
        private readonly StripeClient $stripe,
    ) {}

    public function createCheckoutSession(User $user, MembershipPlan $plan): array { /*...*/ }
    public function activateFromCheckoutSession(CheckoutSession $s): Payment { /*...*/ }
    public function cancel(Membership $m, ?string $reason = null): Membership { /*...*/ }
    public function expireStale(): int { /*...*/ }
}

Los services están en app/Services/. Listado completo: ver Capa de servicios.

3. Form Requests validan + autorizan

Toda entrada pasa por un FormRequest. Nada de $request->validate() inline.

php
final class CreateVacancyRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('vacancy.create');
    }

    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:200'],
            'description' => ['required', 'string', 'max:10000'],
            'salary_min' => ['nullable', 'numeric', 'min:0'],
            'salary_max' => ['nullable', 'numeric', 'gte:salary_min'],
            // ...
        ];
    }

    public function messages(): array
    {
        return [
            'title.required' => 'El título es obligatorio.',
            // mensajes en español
        ];
    }
}

4. Policies para autorización de recursos

Cuando la regla no es "tienes el permiso X" sino "este recurso te pertenece", usa una Policy.

php
final class VacancyPolicy
{
    public function view(User $user, Vacancy $vacancy): bool
    {
        if ($user->hasRole('admin')) return true;
        if ($user->hasRole('recruiter')) return true;
        if ($user->hasRole('company_user')) {
            return $user->companies()
                ->where('companies.id', $vacancy->company_id)
                ->exists();
        }
        return false;
    }

    public function update(User $user, Vacancy $vacancy): bool { /*...*/ }
    public function delete(User $user, Vacancy $vacancy): bool { /*...*/ }
}

Registrada automáticamente por convención (model Vacancy → policy VacancyPolicy).

5. Resources para serialización

Nunca devolver un modelo Eloquent directo del controller. Siempre pasar por Resource:

php
final class VacancyResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'description' => $this->description,
            'state' => $this->state?->value,
            'allowed_transitions' => VacancyStateMachine::allowedValuesFrom($this->state),
            'company' => CompanyResource::make($this->whenLoaded('company')),
            'skills' => SkillResource::collection($this->whenLoaded('skills')),
            'created_at' => $this->created_at?->toIso8601String(),
            // ...
        ];
    }
}

Beneficios:

  • Control explícito de qué campos se exponen.
  • Filtros por rol (ocultar datos sensibles).
  • Evita leaks de columnas internas.

6. Envelope consistente con ApiResponse

Todo response sigue el formato {success, message, data, meta, errors}. Se usa el trait:

php
use App\Support\ApiResponse;

class VacancyController extends Controller
{
    use ApiResponse;

    public function index()
    {
        $vacancies = Vacancy::with('company')->paginate(20);
        return $this->success('OK', VacancyResource::collection($vacancies), [
            'total' => $vacancies->total(),
            'page' => $vacancies->currentPage(),
        ]);
    }
}

Detalle: API REST.

7. Enums PHP 8.3 para estados

Estados de dominio van como enums tipados, no columnas ENUM de MySQL.

php
enum VacancyState: string
{
    case Borrador = 'borrador';
    case Activa = 'activa';
    // ...

    public function label(): string
    {
        return match ($this) {
            self::Borrador => 'Borrador',
            self::Activa => 'Activa',
            // ...
        };
    }
}

En el modelo:

php
protected function casts(): array
{
    return ['state' => VacancyState::class];
}

8. State machines como services estáticos

Las transiciones de estado están encapsuladas en servicios con métodos estáticos:

php
class VacancyStateMachine
{
    public static function graph(): array { /* { from => [to...] } */ }
    public static function allowedFrom(VacancyState $from): array;
    public static function canTransition(VacancyState $from, VacancyState $to): bool;
    public static function allowedValuesFrom(VacancyState $from): array; // para API Resources
}

Uso consistente desde controllers/services. Ver State machines.

Estructura de app/

app/
├── Console/
│   └── Kernel.php        Schedule de jobs (ExpireMembershipsJob diario)

├── Enums/                23 enums tipados
│   ├── CandidateState.php
│   ├── VacancyState.php
│   ├── AssignmentStage.php
│   ├── InterviewState.php
│   ├── UserRole.php
│   └── ...

├── Helpers/
│   ├── StripeClient.php          wrapper sobre stripe/stripe-php
│   └── LocalFileStorage.php      wrapper sobre Laravel Storage + Intervention Image

├── Http/
│   ├── Controllers/
│   │   ├── Controller.php            Base, usa ApiResponse trait
│   │   ├── Api/V1/
│   │   │   ├── Auth/                 register, login, verify-email, ...
│   │   │   ├── Candidate/            Profile, membership, psychometric
│   │   │   ├── Recruiter/            Directory, pipeline, interviews
│   │   │   ├── Company/              Company + vacancies
│   │   │   ├── Admin/                Catalogs, reports, users
│   │   │   └── Shared/               HealthController, ContactController
│   │   └── Webhooks/
│   │       └── StripeWebhookController.php
│   │
│   ├── Middleware/
│   │   └── EnsureRoleMiddleware.php  Wrapper de Spatie con logging
│   │
│   ├── Requests/
│   │   ├── Auth/
│   │   ├── Candidate/
│   │   └── ... (70+ form requests)
│   │
│   └── Resources/V1/
│       ├── UserResource.php
│       ├── CandidateProfileResource.php
│       ├── VacancyResource.php
│       └── ... (30+ resources)

├── Jobs/
│   └── ExpireMembershipsJob.php   Corre diariamente via scheduler (ver /backend/cronjobs)

├── Models/                55+ modelos Eloquent
│   ├── User.php
│   ├── CandidateProfile.php
│   ├── Vacancy.php
│   ├── VacancyAssignment.php
│   ├── Interview.php
│   ├── Payment.php
│   └── ...

├── Notifications/
│   ├── VerifyEmail.php
│   ├── WelcomeNotification.php
│   ├── MembershipActivatedNotification.php
│   ├── InterviewScheduledNotification.php
│   └── ... (11 notifications)

├── Policies/
│   ├── CandidateProfilePolicy.php
│   ├── VacancyPolicy.php
│   ├── CompanyPolicy.php
│   ├── InterviewPolicy.php
│   └── VacancyAssignmentPolicy.php

├── Providers/
│   ├── AppServiceProvider.php        Bind StripeClient, LocalFileStorage
│   └── AuthServiceProvider.php       Gates + Policies registration

├── Services/              13 services
│   ├── AuthService.php
│   ├── ProfileService.php
│   ├── MembershipService.php
│   ├── PsychometricScoringService.php
│   ├── DirectorySearchService.php
│   ├── PipelineService.php
│   ├── InterviewService.php
│   ├── CvGenerationService.php
│   ├── ReportsService.php
│   ├── VacancyStateMachine.php
│   ├── AssignmentStageMachine.php
│   ├── InterviewStateMachine.php
│   └── PsychometricTestService.php

└── Support/
    ├── ApiResponse.php          Trait success/error
    └── ApiExceptionHandler.php  Render excepciones → JSON

Namespaces vs carpetas

Laravel infiere namespace por carpeta. Ejemplo:

app/Http/Controllers/Api/V1/Candidate/ProfileController.php
→ namespace App\Http\Controllers\Api\V1\Candidate;

No renombres carpetas sin actualizar namespaces. Si lo haces, composer dump-autoload se rompe.

Dependency injection

Todo se inyecta por constructor. Laravel resuelve automáticamente vía el container:

php
final class MembershipService
{
    public function __construct(
        private readonly StripeClient $stripe,
        private readonly NotificationDispatcher $dispatcher,
    ) {}
}

// Al invocar:
$service = app(MembershipService::class);
// Laravel resuelve StripeClient automáticamente

Para registrar bindings custom (ej. testing), usa AppServiceProvider::register():

php
$this->app->bind(StripeClient::class, fn () => new StripeClient(config('services.stripe.secret')));

Siguiente

Convenciones del equipo (naming, commits, lint, testing): Convenciones →

Manual de usuario HUMAE · Uso interno