Apariencia
Capa de servicios
La carpeta app/Services/ contiene la lógica de negocio del backend. Los 13 servicios del MVP son donde vive la verdad del dominio.
Principios
1. Un service = un bounded context
Si te encuentras creando un service que necesita importar 10 modelos de dominios distintos, divide.
| Service | Responsabilidad única |
|---|---|
AuthService | Registro, login, verificación |
ProfileService | Perfil del candidato + sub-entidades |
MembershipService | Checkout, activación, expiración |
PsychometricTestService | Iniciar attempts + guardar respuestas |
PsychometricScoringService | Calcular resultado de un attempt |
DirectorySearchService | Búsqueda + filtros del directorio |
PipelineService | CRUD de asignaciones + movimiento de stage |
InterviewService | Agendar, confirmar, reprogramar, cancelar |
CvGenerationService | Generar PDF del candidato |
ReportsService | 9 reportes ejecutivos |
VacancyStateMachine | FSM de vacante |
AssignmentStageMachine | FSM de asignación |
InterviewStateMachine | FSM de entrevista |
2. Dependencias por constructor
php
final class MembershipService
{
public function __construct(
private readonly StripeClient $stripe,
) {}
}Resolución automática por Laravel container. Para mockear en tests:
php
$fakeStripe = Mockery::mock(StripeClient::class);
$fakeStripe->shouldReceive('createCheckoutSession')->andReturn($fakeSession);
$this->app->instance(StripeClient::class, $fakeStripe);3. Transacciones DB
Cualquier operación que modifica más de una tabla debe estar en DB::transaction():
php
public function activateFromCheckoutSession(CheckoutSession $session): Payment
{
return DB::transaction(function () use ($session) {
// Crear membership
// Actualizar payment
// Enviar notification
// Si algo falla, rollback completo
});
}4. No exponer modelos Eloquent directo
Los services devuelven modelos o arrays. El controller los transforma con Resources.
php
// Service
public function createCheckoutSession(User $user, MembershipPlan $plan): array
{
return [
'url' => $session->url,
'session_id' => $session->id,
'payment_id' => $payment->id,
];
}
// Controller
return $this->success('Sesión creada', $result, status: 201);5. Idempotencia
Operaciones sensibles deben ser seguras de repetir.
php
if ($payment->status === PaymentStatus::Succeeded) {
return $payment; // no crear otra membership
}Los 14 services (tour guiado)
AuthService
Responsabilidades: registrar nuevos users, autenticación, verificación email.
Métodos clave:
register(array $data): User— crea user + asigna rol candidate + dispara VerifyEmail notificationauthenticate(string $email, string $password): User— valida credenciales, issue Sanctum tokenverifyEmail(int $userId, string $hash): void— marcaemail_verified_atresendVerificationEmail(User $user): void
ProfileService
Responsabilidades: CRUD del CandidateProfile + 9 sub-entidades (experiences, educations, courses, certifications, references, skills, languages, documents, functional areas multi-select), incluyendo uploads.
Métodos clave:
update(CandidateProfile $profile, array $data): CandidateProfile— extraefunctional_areasdel payload, hacefill()+save()y luegosyncFunctionalAreas()si vino el array.syncFunctionalAreas(CandidateProfile $profile, array $items): void— recibe[{id, is_primary?}], asegura una sola primaria (la primera marcada gana), calculasort_orderpor orden de captura y mantiene en sync el campo legacyfunctional_area_idcon la primaria. Reemplaza completamente el set anterior (sync, no attach).findOrCreate(User $user): CandidateProfile— autocrea perfil vacío al primerGET /me/profile.updateAvatar(User $user, UploadedFile $file): User— guarda en disco local (storage/app/public/avatars/{user_id}/...), actualizaavatar_urlyavatar_pathaddExperience(CandidateProfile $profile, array $data): CandidateExperienceaddDocument(CandidateProfile $profile, UploadedFile $file, DocumentType $type): CandidateDocumentattachSkill(CandidateProfile $profile, int $skillId, SkillLevel $level): voiddetachSkill(CandidateProfile $profile, int $skillId): void
MembershipService
Responsabilidades: todo el ciclo de vida de una membresía.
Métodos clave:
createCheckoutSession(User $user, MembershipPlan $plan): array— Stripe + Payment pendingactivateFromCheckoutSession(CheckoutSession $session): Payment— webhook handler, idempotentecancel(Membership $m, ?string $reason = null): MembershipexpireStale(): int— job diario
Detalles: Integración Stripe.
PsychometricTestService
Responsabilidades: iniciar attempts, guardar respuestas, enviar a scoring.
Métodos clave:
startAttempt(CandidateProfile $profile, PsychometricTest $test): PsychometricAttempt— reutiliza si hay uno en progresosaveAnswer(PsychometricAttempt $attempt, array $data): PsychometricAnswer— upsert por (attempt, question)submitAttempt(PsychometricAttempt $attempt): PsychometricResult— cierra y llama a scoring
PsychometricScoringService
Responsabilidades: calcular el resultado de un attempt terminado.
Método principal:
score(PsychometricAttempt $attempt): PsychometricResult
Lógica:
- Agrupa answers por
dimension(de la question). - Aplica reverse scoring si
is_reverse_scored. - Multiplica por
weight. - Suma total + calcula grade A/B/C/D según max posible.
- Guarda
PsychometricResult. Idempotente.
DirectorySearchService
Responsabilidades: búsqueda filtrada de candidatos activos.
Método principal:
search(Request $request): LengthAwarePaginator
Filtros soportados: membresía activa, estado, texto (q), ubicación, nivel carrera, área funcional (single legacy), categoría empleado/practicante (candidate_kind), áreas de interés multi-select (functional_area_ids[] OR) y área principal (primary_functional_area_id), experiencia, salario, remoto, reubicación, skills (AND), idiomas (AND), favoritos.
Patrón "apply-each-filter" — cada filtro es un método privado:
php
public function search(Request $request): LengthAwarePaginator
{
$query = CandidateProfile::query()->with(['user', 'skills', 'languages']);
$this->applyMembershipFilter($query, $request);
$this->applyStateFilter($query, $request);
$this->applyTextSearch($query, $request);
$this->applyScalarFilters($query, $request);
$this->applyExperienceFilters($query, $request);
$this->applySalaryFilter($query, $request);
$this->applyFlagFilters($query, $request);
$this->applySkillsFilter($query, $request);
$this->applyLanguagesFilter($query, $request);
$this->applyFunctionalAreasFilter($query, $request); // candidate_functional_areas pivot
$this->applyFavoriteFilter($query, $request);
return $query->orderByDesc('updated_at')->paginate(...);
}MatchingService
Responsabilidades: scoring estructurado vacante↔candidato (PDF cosasfaltanteshumae, "Ajuste en la lógica de matching"). No es ML; son reglas deterministas, replicables y debuggeables.
Métodos:
score(Vacancy $vacancy, CandidateProfile $candidate): array— devuelve{total: 0..100, breakdown: {kind, areas, education, experience, skills, salary}}.suggestForVacancy(Vacancy $vacancy, int $minScore = 0, int $limit = 20): array— filtra membresía activa + estados visibles, excluye candidatos ya asignados a esa vacante, ordena por score descendente.
Pesos del score total:
| Eje | Peso | Lógica |
|---|---|---|
kind | 25 | exacto si target_candidate_kind === candidate_kind; 60 % parcial si vacante = any; 0 si discrepa o null. |
areas | 25 | match con vacancy.functional_area_id: 100 % si lo tiene como primaria, 70 % como secundaria, 0 si no lo tiene. Si vacante no especifica área → 40 % neutro. |
education | 15 | degree_level_id máximo del candidato ≥ requerido por la vacante. Sin requisito → 100 %. |
experience | 15 | dentro de [min, max] de la vacante → 100 %. Bajo el mínimo → proporcional × 0.5. Sobre el máximo → 70 %. |
skills | 15 | % de vacancy.skills que el candidato también tiene. Sin requisitos → 100 %. |
salary | 5 | expected_salary_min ≤ vacancy.salary_max → 100 %. Sin datos en alguno → 100 %. |
Endpoint que lo expone: GET /api/v1/vacancies/{vacancy}/suggested-candidates?min_score=&limit= en VacancyController::suggestedCandidates.
php
// app/Services/MatchingService.php (extracto)
public function score(Vacancy $vacancy, CandidateProfile $candidate): array
{
$breakdown = [
'kind' => $this->scoreKind($vacancy, $candidate),
'areas' => $this->scoreAreas($vacancy, $candidate),
'education' => $this->scoreEducation($vacancy, $candidate),
'experience' => $this->scoreExperience($vacancy, $candidate),
'skills' => $this->scoreSkills($vacancy, $candidate),
'salary' => $this->scoreSalary($vacancy, $candidate),
];
return ['total' => array_sum($breakdown), 'breakdown' => $breakdown];
}Tests: tests/Feature/Services/MatchingServiceTest.php (8 escenarios) + tests/Feature/Api/V1/Companies/SuggestedCandidatesEndpointTest.php (3).
PipelineService
Responsabilidades: crear asignaciones, mover entre stages, notas.
Métodos clave:
assign(Vacancy $vacancy, CandidateProfile $profile, User $recruiter, array $data): VacancyAssignmentmoveToStage(VacancyAssignment $assignment, AssignmentStage $to, ?string $rejectionReason = null): VacancyAssignmentaddNote(VacancyAssignment $assignment, User $author, string $body, string $visibility): VacancyAssignmentNote
Al mover a stage, valida con AssignmentStageMachine::canTransition().
InterviewService
Responsabilidades: agendar, confirmar, reprogramar, cancelar entrevistas.
Métodos clave:
schedule(VacancyAssignment $a, User $scheduledBy, array $data): Interview— transición de vacancy implícitaconfirm(Interview $interview): Interview— idempotentereschedule(Interview $i, User $actor, Carbon $newDate, ?string $reason, array $data): Interviewcancel(Interview $i, ?string $reason = null): Interview
Cada método dispara notifications a las partes relevantes.
CvGenerationService
Responsabilidades: generar el CV en PDF.
Método principal:
generate(User $user): string— devuelve bytes del PDF
Pipeline:
- Carga
CandidateProfilecon 8 relaciones eager. - Transforma a array para la vista Blade.
- Renderiza
resources/views/pdf/cv.blade.php. - Instancia Dompdf, returns binary.
El controller setea headers:
php
return response($pdf)->withHeaders([
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'attachment; filename="CV_' . $safeName . '.pdf"',
]);ReportsService
Responsabilidades: 9 reportes ejecutivos agregados.
Métodos (uno por reporte):
candidatesRegistered(Carbon $from, Carbon $to): arrayactiveMemberships(): arraypaymentsInPeriod(Carbon $from, Carbon $to): arrayexpiringMemberships(int $days): arrayvacanciesByState(): arrayinterviewsInPeriod(Carbon $from, Carbon $to): arrayrecruiterEffectiveness(): arraytimeToFill(): arraymostSearchedProfiles(int $limit): array
Usa DB::table() y selectRaw con bindings ? (no Eloquent) para queries de agregación eficientes. Maneja SQLite vs MySQL con DB::getDriverName().
VacancyStateMachine, AssignmentStageMachine, InterviewStateMachine
State machines puros, solo métodos estáticos. Ver Máquinas de estado para transiciones permitidas.
Cada uno expone:
graph(): array— mapa{from => [to...]}allowedFrom(Enum $from): array— lista de enums permitidoscanTransition(Enum $from, Enum $to): boolallowedValuesFrom(Enum $from): array— lista de strings para API
AssignmentStageMachine además tiene:
timestampField(AssignmentStage $stage): array— qué columna setear (presented_at,interviewed_at,hired_at, ...)
Helpers
app/Helpers/ contiene wrappers sobre SDKs externos, inyectables como services.
StripeClient
php
final class StripeClient
{
private \Stripe\StripeClient $client;
public function __construct()
{
$this->client = new \Stripe\StripeClient(config('services.stripe.secret'));
}
public function createCheckoutSession(array $params): CheckoutSession
{
return $this->client->checkout->sessions->create($params);
}
public function constructWebhookEvent(string $payload, string $sigHeader): \Stripe\Event
{
return \Stripe\Webhook::constructEvent(
$payload,
$sigHeader,
config('services.stripe.webhook_secret'),
);
}
}LocalFileStorage
php
final class LocalFileStorage
{
/**
* @param array{disk?: string, transform?: array{width?: int, height?: int}} $options
* @return array{url: string|null, public_id: string, mime_type: string|null, size: int|null}
*/
public function upload(UploadedFile $file, string $folder, array $options = []): array
{
$disk = $options['disk'] ?? 'public';
// Opcionalmente aplica resize con Intervention Image si llega 'transform'.
$path = $options['transform'] ?? null
? $this->storeTransformed($file, $folder, $disk, $options['transform'])
: $this->storeRaw($file, $folder, $disk);
return [
'url' => $disk === 'public' ? Storage::disk('public')->url($path) : null,
'public_id' => $path,
// ...
];
}
public function destroy(string $publicId, string $disk = 'public'): void
{
Storage::disk($disk)->delete($publicId);
}
}Guarda archivos en storage/app/public/ (avatares, logos) o en storage/app/private/ (documentos sensibles servidos vía endpoint autenticado). Ver Storage local.
Patrón: servicio con transacción + notificación
Plantilla estándar para operaciones críticas:
php
public function hireAssignment(VacancyAssignment $assignment): VacancyAssignment
{
return DB::transaction(function () use ($assignment) {
// 1. Validar estado actual con FSM
if (! AssignmentStageMachine::canTransition(
$assignment->stage,
AssignmentStage::Hired
)) {
throw new RuntimeException('No se puede contratar desde este estado.');
}
// 2. Mutar el modelo
$assignment->update([
'stage' => AssignmentStage::Hired,
'hired_at' => now(),
]);
// 3. Transición en cascada
$vacancy = $assignment->vacancy;
if ($vacancy->state !== VacancyState::Cubierta) {
$vacancy->update(['state' => VacancyState::Cubierta]);
}
// 4. Marcar otras asignaciones como withdrawn
$vacancy->assignments()
->where('id', '!=', $assignment->id)
->whereNotIn('stage', [AssignmentStage::Rejected, AssignmentStage::Withdrawn, AssignmentStage::Hired])
->update([
'stage' => AssignmentStage::Withdrawn,
'withdrawn_at' => now(),
'rejection_reason' => 'Vacante cubierta por otro candidato',
]);
// 5. Disparar notifications
$assignment->candidateProfile->user->notify(new CandidateHiredNotification($assignment));
$this->notifyCompany($assignment->vacancy->company, new CandidateHiredNotification($assignment));
return $assignment->fresh();
});
}Cómo probar services
Ver Testing para patterns completos. Ejemplo rápido:
php
// tests/Feature/Services/MembershipServiceTest.php
use App\Services\MembershipService;
use App\Models\{User, MembershipPlan};
it('cancel() marks membership as cancelled with reason', function () {
$plan = MembershipPlan::factory()->create();
$membership = Membership::factory()->create([
'user_id' => User::factory()->create()->id,
'membership_plan_id' => $plan->id,
'status' => MembershipStatus::Active,
]);
$service = app(MembershipService::class);
$result = $service->cancel($membership, 'duplicate_purchase');
expect($result->status->value)->toBe('cancelled');
expect($result->cancel_reason)->toBe('duplicate_purchase');
});Cuándo NO crear un nuevo service
- Si la "lógica" es solo
Model::create($data)→ pon en el controller. - Si es una query aislada de lectura → hazla en el controller o un scope del modelo.
- Si es solo transformación de datos → pon en el Resource.
Siguiente
Notifications, jobs y scheduler: Notifications y jobs →

