Skip to content

Modelo de datos

55+ tablas de dominio divididas en 8 bounded contexts. Este documento es la referencia técnica de los dev; para una visión funcional ve a Arquitectura de alto nivel.

Bounded contexts

ContextTablas clave
Identity & Authusers, password_reset_tokens, personal_access_tokens, roles, permissions, model_has_roles, model_has_permissions, role_has_permissions
Perfil candidatocandidate_profiles, candidate_experiences, candidate_educations, candidate_courses, candidate_certifications, candidate_references, candidate_documents, candidate_skills, candidate_languages, candidate_functional_areas
Membresía + pagosmembership_plans, memberships, payments, salary_currencies
Psicométricaspsychometric_tests, psychometric_test_sections, psychometric_questions, psychometric_question_options, psychometric_attempts, psychometric_answers, psychometric_results
Empresas + vacantescompanies, company_members, vacancies, vacancy_categories, vacancy_types, vacancy_shifts, vacancy_tags, vacancy_tag_vacancy
Pipelinevacancy_assignments, vacancy_assignment_notes, directory_favorites
Entrevistasinterviews, interview_reschedules
Catálogoscountries, states, cities, industries, company_sizes, ownership_types, functional_areas, career_levels, positions, degree_levels, skills, languages
Sistemanotifications, failed_jobs, activity_log (Spatie), settings, contact_submissions

ERD resumido

Usuario y perfil

users (1) ──── (0..1) candidate_profiles
users (1) ──── (0..n) memberships
users (1) ──── (0..n) payments
users (1) ──── (0..n) notifications
users (1) ──── (0..n) company_members ──── (N..1) companies

candidate_profiles (1) ──── (0..n) candidate_functional_areas ──── (N..1) functional_areas
                                       │ (pivot con is_primary, sort_order)

                                       └── exactamente una con is_primary=true
                                            espejada en candidate_profiles.functional_area_id

Campos del perfil agregados por el PDF cosasfaltanteshumae

candidate_profiles (migración 2026_04_29_213001_add_candidate_kind_to_candidate_profiles.php):

ColumnaTipoNotas
candidate_kindstring(20) nullable, indexadoenum App\Enums\CandidateKind: employee, intern.
other_area_textstring(200) nullableTexto libre "otra área" del PDF (no participa del matching).

Tabla pivote candidate_functional_areas (migración 2026_04_29_213101_create_candidate_functional_areas_table.php):

ColumnaTipoNotas
candidate_profile_idFK cascadeOnDelete
functional_area_idFK cascadeOnDelete
is_primaryboolean default falseexactamente una true por candidato
sort_orderunsignedSmallIntegerorden de captura
Unique(candidate_profile_id, functional_area_id)
Index(candidate_profile_id, is_primary)acelera el filtro primary_functional_area_id del directorio

vacancies (migración 2026_04_29_213002_add_target_candidate_kind_to_vacancies.php):

ColumnaTipoNotas
target_candidate_kindstring(20) default any indexadoenum App\Enums\VacancyTargetKind: employee, intern, any.

Pipeline

vacancies (N..1) ──── companies
vacancies (1) ──── (0..n) vacancy_assignments
vacancy_assignments (N..1) ──── candidate_profiles
vacancy_assignments (1) ──── (0..n) interviews
vacancy_assignments (1) ──── (0..n) vacancy_assignment_notes
interviews (1) ──── (0..n) interview_reschedules

Psicométricas

psychometric_tests (1) ──── (0..n) psychometric_test_sections
psychometric_tests (1) ──── (0..n) psychometric_questions
psychometric_questions (1) ──── (0..n) psychometric_question_options
candidate_profiles (1) ──── (0..n) psychometric_attempts
psychometric_attempts (1) ──── (0..n) psychometric_answers
psychometric_attempts (1) ──── (0..1) psychometric_results

Para ERD visual completo, ver ARCHITECTURE.md § 4 en el repositorio.

Migraciones — convenciones

Orden

Las migraciones corren en orden alfabético por timestamp. Laravel 12 también permite anidar por carpetas.

HUMAE usa un timestamp en formato YYYY_MM_DD_HHMMSS_action_resource.php:

database/migrations/
├── 2026_01_01_000001_create_users_table.php
├── 2026_01_01_000002_create_roles_and_permissions_tables.php
├── 2026_01_02_000001_create_countries_table.php
├── 2026_01_02_000002_create_states_table.php
├── 2026_01_02_000003_create_cities_table.php
├── 2026_01_03_000001_create_candidate_profiles_table.php
└── ...

Si una migración depende de otra, asegúrate que su timestamp sea posterior.

Reglas para migraciones nuevas

  • Una migración = un cambio lógico. No mezcles crear tablas + agregar columnas.

  • Nunca editar una migración ya mergeada a main. Si necesitas cambiar algo, haz una nueva migración add_X_to_Y o modify_X_in_Y.

  • Foreign keys con ->constrained() siempre, salvo excepciones documentadas:

    php
    Schema::create('vacancy_assignments', function (Blueprint $table) {
        $table->id();
        $table->foreignId('vacancy_id')->constrained()->cascadeOnDelete();
        $table->foreignId('candidate_profile_id')->constrained()->restrictOnDelete();
        $table->foreignId('assigned_by')->nullable()->constrained('users')->nullOnDelete();
        // ...
    });
  • Enums en columnas: no uses $table->enum(). Usa string + cast a PHP enum.

    php
    $table->string('state')->default('borrador')->index();
  • Índices explícitos en columnas de filtrado. Estan listados en PERFORMANCE.md — consulta antes de agregar uno.

  • Unique constraints para relaciones que no se duplican:

    php
    $table->unique(['vacancy_id', 'candidate_profile_id']);

Índices largos

MySQL 8 tiene límite de 64 caracteres para nombres de índice. Si tu constraint genera un nombre más largo, dale nombre explícito:

php
// ❌ MySQL truncará si supera 64 chars
$table->unique(['psychometric_attempt_id', 'psychometric_question_id']);

// ✅ nombre explícito
$table->unique(['psychometric_attempt_id', 'psychometric_question_id'], 'uq_pa_pq');

Los índices para queries de búsqueda siguen el mismo patrón:

php
$table->index(['candidate_profile_id', 'psychometric_test_id'], 'idx_attempt_candidate_test');

datetime vs timestamp

Para campos con valores que pueden ser null o estar antes de 1970 (fechas de nacimiento, expiración):

php
// ✅ Preferido
$table->datetime('birth_date')->nullable();
$table->datetime('expires_at')->nullable();

// ❌ Evitar — en MySQL estricto falla con NO_ZERO_DATE
$table->timestamp('birth_date')->nullable();

Los campos created_at, updated_at, deleted_at siguen usando $table->timestamps() y $table->softDeletes() (son internos de Laravel).

Soft deletes

Modelos que necesitan recuperarse:

php
class CandidateProfile extends Model
{
    use SoftDeletes;
}

Y en la migración:

php
$table->softDeletes(); // agrega deleted_at nullable

Tablas con soft delete en HUMAE:

  • users, candidate_profiles, companies, vacancies, vacancy_assignments, interviews, candidate_documents

Catálogos (skills, cities, etc.) NO tienen soft delete — usan is_active.

Enums

app/Enums/ tiene 23 enums. Todos extienden de string (enums typed):

php
enum UserRole: string
{
    case Candidate = 'candidate';
    case Recruiter = 'recruiter';
    case CompanyUser = 'company_user';
    case Admin = 'admin';
}

Por qué enums tipados y no ENUM de MySQL

  • Refactoring-friendly: renombrar un case es un cambio de código, no una migración.
  • Type-safe: PHPStan y el IDE te cachean errores.
  • Testeable: foreach (UserRole::cases() as $case) funciona.

Casteo en modelos

php
protected function casts(): array
{
    return [
        'role' => UserRole::class,
        'state' => CandidateState::class,
        'status' => PaymentStatus::class,
    ];
}

Después puedes usar:

php
$user->role === UserRole::Admin; // comparación estricta
$user->role->value; // 'admin' (valor string en DB)
$user->role->name;  // 'Admin' (nombre del case)

Factories

Cada modelo tiene su factory en database/factories/. Son obligatorias para testing.

php
/**
 * @extends Factory<CandidateProfile>
 */
class CandidateProfileFactory extends Factory
{
    protected $model = CandidateProfile::class;

    public function definition(): array
    {
        return [
            'user_id' => User::factory(),
            'first_name' => fake()->firstName(),
            'last_name' => fake()->lastName(),
            'headline' => fake()->jobTitle(),
            'summary' => fake()->paragraph(),
            'state' => CandidateState::Activo->value,
            'years_of_experience' => fake()->numberBetween(0, 20),
            'expected_salary_min' => fake()->numberBetween(15000, 80000),
        ];
    }

    public function active(): static
    {
        return $this->state(['state' => CandidateState::Activo->value]);
    }

    public function withMembership(): static
    {
        return $this->afterCreating(function (CandidateProfile $profile) {
            Membership::factory()->create([
                'user_id' => $profile->user_id,
                'status' => MembershipStatus::Active,
            ]);
        });
    }
}

En tests:

php
$candidate = CandidateProfile::factory()
    ->withMembership()
    ->create();

// Crear muchos
$three = CandidateProfile::factory()->count(3)->active()->create();

Uso con relationships

Si un factory incluye 'user_id' => User::factory(), Laravel crea automáticamente un User y usa su id. No hace falta crear el user antes.

php
$profile = CandidateProfile::factory()->create();
// Automáticamente creó User asociado
$profile->user; // instancia de User

Seeders

database/seeders/ tiene los seeders para poblar catálogos y datos base.

bash
# Correr todos (los del DatabaseSeeder)
php artisan db:seed

# Uno específico
php artisan db:seed --class=SkillsSeeder

# Reset completo + seed
php artisan migrate:fresh --seed

Seeders incluidos

SeederQué hace
DatabaseSeederOrquesta los demás
RolesAndPermissionsSeeder45 permisos + 4 roles
SalaryCurrenciesSeederMXN, USD, EUR, ...
MembershipPlansSeederPlan candidate_6m
CountriesSeederMéxico + 10 latinoamericanos
StatesSeeder32 estados de México
MajorCitiesSeederTop 50 ciudades
IndustriesSeeder20 industrias
CompanySizesSeeder1-10, 11-50, ...
OwnershipTypesSeederPrivada, pública, ONG, gobierno
FunctionalAreasSeeder15 áreas (TI, RH, Marketing, ...)
CareerLevelsSeeder7 niveles (Trainee → Director)
PositionsSeeder200+ puestos
DegreeLevelsSeeder6 niveles educativos
SkillsSeeder150+ habilidades
LanguagesSeeder20 idiomas con niveles MCER
VacancyCategoriesSeederCategorías (Full-stack, QA, ...)
VacancyTypesSeederTiempo completo, freelance, ...
VacancyShiftsSeederDiurno, nocturno, mixto
PsychometricTestsSeederBig Five + estructura
BigFiveQuestionsSeeder25 preguntas en español

Ver el schema

Via artisan

bash
# Listar todas las tablas
php artisan db:show

# Ver una tabla específica
php artisan db:table users

Via MySQL directo

sql
SHOW TABLES;
DESCRIBE users;
SHOW CREATE TABLE vacancies;

Via IDE

Generar model annotations:

bash
php artisan ide-helper:models -W

Agrega docblock @property completo a cada modelo — útil para PHPStan nivel 8.

Datos sensibles

Campos que nunca se loguean ni se envían al frontend en Resources:

CampoModeloRazón
passwordusersHash bcrypt
remember_tokenusersSesión
stripe_secret_* (si existieran)Keys de API
birth_datecandidate_profilesPII, solo admin
curp, rfccandidate_profilesPII fiscal
contact_email, contact_phonecandidate_profilesSolo recruiter + admin
stripe_customer_id, stripe_payment_intent_idpaymentsInternos

Los Resources los filtran explícitamente según el rol del request.

Siguiente

Diseño del contrato HTTP: API REST →

Manual de usuario HUMAE · Uso interno