Skip to content

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.

ServiceResponsabilidad única
AuthServiceRegistro, login, verificación
ProfileServicePerfil del candidato + sub-entidades
MembershipServiceCheckout, activación, expiración
PsychometricTestServiceIniciar attempts + guardar respuestas
PsychometricScoringServiceCalcular resultado de un attempt
DirectorySearchServiceBúsqueda + filtros del directorio
PipelineServiceCRUD de asignaciones + movimiento de stage
InterviewServiceAgendar, confirmar, reprogramar, cancelar
CvGenerationServiceGenerar PDF del candidato
ReportsService9 reportes ejecutivos
VacancyStateMachineFSM de vacante
AssignmentStageMachineFSM de asignación
InterviewStateMachineFSM 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 notification
  • authenticate(string $email, string $password): User — valida credenciales, issue Sanctum token
  • verifyEmail(int $userId, string $hash): void — marca email_verified_at
  • resendVerificationEmail(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 — extrae functional_areas del payload, hace fill() + save() y luego syncFunctionalAreas() si vino el array.
  • syncFunctionalAreas(CandidateProfile $profile, array $items): void — recibe [{id, is_primary?}], asegura una sola primaria (la primera marcada gana), calcula sort_order por orden de captura y mantiene en sync el campo legacy functional_area_id con la primaria. Reemplaza completamente el set anterior (sync, no attach).
  • findOrCreate(User $user): CandidateProfile — autocrea perfil vacío al primer GET /me/profile.
  • updateAvatar(User $user, UploadedFile $file): User — guarda en disco local (storage/app/public/avatars/{user_id}/...), actualiza avatar_url y avatar_path
  • addExperience(CandidateProfile $profile, array $data): CandidateExperience
  • addDocument(CandidateProfile $profile, UploadedFile $file, DocumentType $type): CandidateDocument
  • attachSkill(CandidateProfile $profile, int $skillId, SkillLevel $level): void
  • detachSkill(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 pending
  • activateFromCheckoutSession(CheckoutSession $session): Payment — webhook handler, idempotente
  • cancel(Membership $m, ?string $reason = null): Membership
  • expireStale(): 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 progreso
  • saveAnswer(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:

  1. Agrupa answers por dimension (de la question).
  2. Aplica reverse scoring si is_reverse_scored.
  3. Multiplica por weight.
  4. Suma total + calcula grade A/B/C/D según max posible.
  5. 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:

EjePesoLógica
kind25exacto si target_candidate_kind === candidate_kind; 60 % parcial si vacante = any; 0 si discrepa o null.
areas25match 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.
education15degree_level_id máximo del candidato ≥ requerido por la vacante. Sin requisito → 100 %.
experience15dentro de [min, max] de la vacante → 100 %. Bajo el mínimo → proporcional × 0.5. Sobre el máximo → 70 %.
skills15% de vacancy.skills que el candidato también tiene. Sin requisitos → 100 %.
salary5expected_salary_minvacancy.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): VacancyAssignment
  • moveToStage(VacancyAssignment $assignment, AssignmentStage $to, ?string $rejectionReason = null): VacancyAssignment
  • addNote(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ícita
  • confirm(Interview $interview): Interview — idempotente
  • reschedule(Interview $i, User $actor, Carbon $newDate, ?string $reason, array $data): Interview
  • cancel(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:

  1. Carga CandidateProfile con 8 relaciones eager.
  2. Transforma a array para la vista Blade.
  3. Renderiza resources/views/pdf/cv.blade.php.
  4. 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): array
  • activeMemberships(): array
  • paymentsInPeriod(Carbon $from, Carbon $to): array
  • expiringMemberships(int $days): array
  • vacanciesByState(): array
  • interviewsInPeriod(Carbon $from, Carbon $to): array
  • recruiterEffectiveness(): array
  • timeToFill(): array
  • mostSearchedProfiles(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 permitidos
  • canTransition(Enum $from, Enum $to): bool
  • allowedValuesFrom(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 →

Manual de usuario HUMAE · Uso interno