Apariencia
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
| Context | Tablas clave |
|---|---|
| Identity & Auth | users, password_reset_tokens, personal_access_tokens, roles, permissions, model_has_roles, model_has_permissions, role_has_permissions |
| Perfil candidato | candidate_profiles, candidate_experiences, candidate_educations, candidate_courses, candidate_certifications, candidate_references, candidate_documents, candidate_skills, candidate_languages, candidate_functional_areas |
| Membresía + pagos | membership_plans, memberships, payments, salary_currencies |
| Psicométricas | psychometric_tests, psychometric_test_sections, psychometric_questions, psychometric_question_options, psychometric_attempts, psychometric_answers, psychometric_results |
| Empresas + vacantes | companies, company_members, vacancies, vacancy_categories, vacancy_types, vacancy_shifts, vacancy_tags, vacancy_tag_vacancy |
| Pipeline | vacancy_assignments, vacancy_assignment_notes, directory_favorites |
| Entrevistas | interviews, interview_reschedules |
| Catálogos | countries, states, cities, industries, company_sizes, ownership_types, functional_areas, career_levels, positions, degree_levels, skills, languages |
| Sistema | notifications, 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_idCampos del perfil agregados por el PDF cosasfaltanteshumae
candidate_profiles (migración 2026_04_29_213001_add_candidate_kind_to_candidate_profiles.php):
| Columna | Tipo | Notas |
|---|---|---|
candidate_kind | string(20) nullable, indexado | enum App\Enums\CandidateKind: employee, intern. |
other_area_text | string(200) nullable | Texto 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):
| Columna | Tipo | Notas |
|---|---|---|
candidate_profile_id | FK cascadeOnDelete | |
functional_area_id | FK cascadeOnDelete | |
is_primary | boolean default false | exactamente una true por candidato |
sort_order | unsignedSmallInteger | orden 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):
| Columna | Tipo | Notas |
|---|---|---|
target_candidate_kind | string(20) default any indexado | enum 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_reschedulesPsicomé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_resultsPara 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_Yomodify_X_in_Y.Foreign keys con
->constrained()siempre, salvo excepciones documentadas:phpSchema::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(). Usastring+ 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 nullableTablas 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 UserSeeders
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 --seedSeeders incluidos
| Seeder | Qué hace |
|---|---|
DatabaseSeeder | Orquesta los demás |
RolesAndPermissionsSeeder | 45 permisos + 4 roles |
SalaryCurrenciesSeeder | MXN, USD, EUR, ... |
MembershipPlansSeeder | Plan candidate_6m |
CountriesSeeder | México + 10 latinoamericanos |
StatesSeeder | 32 estados de México |
MajorCitiesSeeder | Top 50 ciudades |
IndustriesSeeder | 20 industrias |
CompanySizesSeeder | 1-10, 11-50, ... |
OwnershipTypesSeeder | Privada, pública, ONG, gobierno |
FunctionalAreasSeeder | 15 áreas (TI, RH, Marketing, ...) |
CareerLevelsSeeder | 7 niveles (Trainee → Director) |
PositionsSeeder | 200+ puestos |
DegreeLevelsSeeder | 6 niveles educativos |
SkillsSeeder | 150+ habilidades |
LanguagesSeeder | 20 idiomas con niveles MCER |
VacancyCategoriesSeeder | Categorías (Full-stack, QA, ...) |
VacancyTypesSeeder | Tiempo completo, freelance, ... |
VacancyShiftsSeeder | Diurno, nocturno, mixto |
PsychometricTestsSeeder | Big Five + estructura |
BigFiveQuestionsSeeder | 25 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 usersVia MySQL directo
sql
SHOW TABLES;
DESCRIBE users;
SHOW CREATE TABLE vacancies;Via IDE
Generar model annotations:
bash
php artisan ide-helper:models -WAgrega 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:
| Campo | Modelo | Razón |
|---|---|---|
password | users | Hash bcrypt |
remember_token | users | Sesión |
stripe_secret_* (si existieran) | — | Keys de API |
birth_date | candidate_profiles | PII, solo admin |
curp, rfc | candidate_profiles | PII fiscal |
contact_email, contact_phone | candidate_profiles | Solo recruiter + admin |
stripe_customer_id, stripe_payment_intent_id | payments | Internos |
Los Resources los filtran explícitamente según el rol del request.
Siguiente
Diseño del contrato HTTP: API REST →

