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_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): 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