Apariencia
Arquitectura
HUMAE backend es Laravel 12 con arquitectura por capas explícita. El principio rector es: controllers delgados, services gordos.
Stack
| Componente | Versión | Razón |
|---|---|---|
| Laravel | 12.x | Framework base |
| PHP | 8.3+ | Typed properties, enums, readonly |
| MySQL | 8.0+ | InnoDB + generated columns |
| Sanctum | 4.x | Auth SPA + tokens |
| Spatie Permission | 7.x | Roles + permisos |
| Spatie Activitylog | 5.x | Auditoría (instalado, no activo aún) |
| Stripe PHP | 20.x | Pasarela de pagos |
| Intervention Image | 3.x | Resize de avatares (GD/Imagick) |
| DomPDF | 3.x | Generación de PDF |
| SMTP local (Postfix) | — | Envío de correos (Laravel smtp driver a 127.0.0.1:25) |
| Pest | 3.x | Testing |
| Larastan | 3.x (level 8) | Análisis estático |
| Pint | 1.x | Lint/formato |
| Scribe | 5.x | API 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 → JSONNamespaces 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áticamentePara 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 →

