Preparación para la entrevista técnica

Laboral Group · Test 001 Software Developer Realizado · 07/05/2026

Este documento es tu guía completa para revisar antes de la entrevista presencial. Incluye qué hiciste, por qué lo hiciste así, las mejoras que mencionarás si te preguntan, y un banco de preguntas probables con respuestas. Léelo entero esta noche y mañana releélo rápido antes de entrar.

1 · Resumen ejecutivo

3Tareas requeridas
4Endpoints en api.php
6Campos por usuario

Qué pedía el test

Una mini-aplicación de gestión de usuarios con:

  1. Listado de usuarios con búsqueda y paginación de 10 en 10.
  2. Modificar usuario al pulsar sobre él (formulario en modal/página).
  3. Nuevo usuario mediante un botón en la cabecera.

Stack obligatorio: Bootstrap + jQuery + AJAX + PHP devolviendo JSON + MySQL. Todo testeado en XAMPP.

Qué entregaste

  • Template SB Admin 2 (gratuito, Bootstrap 4) personalizado en tables.html.
  • Cliente jQuery con AJAX en js/usuarios.js (listar, paginar, buscar, crear, editar).
  • Backend PHP en php/api.php con un único endpoint y parámetro action.
  • BBDD MySQL pruebatecnica · tabla users.
  • Validación de DNI español (8 dígitos + letra) en servidor.
  • Validación de campos obligatorios y longitudes mínimas.
  • Buscador en tiempo real sobre nombre, email, DNI y teléfono.
  • Selector de avatar (4 fotos predefinidas) en los modales de crear/editar.

2 · Requisitos del PDF (palabra por palabra)

Tarea 1 · Listado de Usuarios

Listado con campos: DNI, nombre completo, fecha nacimiento, teléfono y email.

  • Paginación (carga de 10 en 10).
  • Buscador.
  • Al seleccionar un usuario → abrir formulario de modificación.
  • En la parte superior, botón "Nuevo Usuario".

Tarea 2 · Modificar Usuario

  • Al pulsar un usuario → formulario con sus datos.
  • Obligatorios: DNI, nombre completo y fecha de nacimiento.
  • Tener en cuenta longitudes fijas: DNI, fecha y teléfono.

Tarea 3 · Nuevo Usuario

  • Botón en el listado → formulario para introducir datos nuevos y guardar en BBDD.
  • Mismas reglas de obligatoriedad y longitudes que en Modificar.

Metodología impuesta

  • Front-end: Bootstrap (template a descargar del apartado Material).
  • Back-end: AJAX con jQuery → llamada a PHP que devuelve JSON (estilo "API rest").
  • Render: jQuery/JavaScript parsea el JSON y dibuja la información.
  • BBDD: MySQL, tabla obligatoria users.
  • Testing local: XAMPP (Apache + PHP + MySQL).

3 · Stack utilizado

PHP 8 (mysqli) MySQL 8 jQuery 3.x Bootstrap 4 (SB Admin 2) Font Awesome 5 DataTables (incluido por template, no usado) XAMPP local

El stack es el exacto que pedía la prueba. No hay framework moderno (React, Laravel...) porque el enunciado lo prohíbe implícitamente: pedía AJAX con jQuery directo contra PHP devolviendo JSON.

4 · Arquitectura de la solución

┌─────────────────────────────────────────────────────────────────┐ │ NAVEGADOR │ │ │ │ tables.html ──┐ │ │ ├──► usuarios.js (jQuery) │ │ modales │ │ │ │ búsqueda │ │ $.ajax POST │ │ paginación ◄──┘ │ │ │ ▼ │ └──────────────────────────┼──────────────────────────────────────┘ │ action=listar|obtener|crear|actualizar ▼ ┌─────────────────────────────────────────────────────────────────┐ │ APACHE + PHP │ │ │ │ php/api.php (single endpoint, switch action) │ │ │ │ │ │ SQL (mysqli) │ │ ▼ │ └──────────────────────────┼──────────────────────────────────────┘ ▼ ┌──────────────────────┐ │ MySQL │ │ db: pruebatecnica │ │ tabla: users │ └──────────────────────┘

Patrón aplicado

  • Cliente "SPA-light": una sola página HTML (tables.html) que nunca se recarga; todos los cambios entran por AJAX.
  • Backend tipo "controlador único": api.php recibe un parámetro action y enruta internamente. Es el equivalente "casero" a un router REST.
  • Formato de intercambio: JSON en ambas direcciones (request por application/x-www-form-urlencoded al ser $.ajax POST, response con Content-Type: application/json).

5 · Esquema de base de datos

La tabla que da soporte a toda la aplicación:

CREATE DATABASE IF NOT EXISTS pruebatecnica
    CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

USE pruebatecnica;

CREATE TABLE users (
    id               INT AUTO_INCREMENT PRIMARY KEY,
    nombre           VARCHAR(150) NOT NULL,
    dni              VARCHAR(9)   NOT NULL,
    fecha_nacimiento DATE         NOT NULL,
    telefono         VARCHAR(20)  DEFAULT NULL,
    email            VARCHAR(150) DEFAULT NULL,
    foto             VARCHAR(100) DEFAULT 'undraw_profile.svg',
    UNIQUE KEY uniq_dni (dni)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Si te preguntan por qué VARCHAR(9) en DNI: porque el DNI español tiene exactamente 8 dígitos + 1 letra = 9 caracteres. Es un campo de longitud fija, y al ponerlo UNIQUE garantizamos a nivel de BBDD que no haya duplicados (defensa en profundidad).
Posible pregunta: ¿por qué no CHAR(9) si es longitud fija? Respuesta: la diferencia de rendimiento entre CHAR y VARCHAR para campos tan pequeños es despreciable en InnoDB, y VARCHAR es más estándar. Si insistieran, CHAR(9) sería marginalmente mejor.

6.1 · tables.html · estructura

Tomé el archivo tables.html original del template SB Admin 2 y reemplacé la tabla de ejemplo por la mía. Las piezas clave que añadí:

Buscador y botón "Nuevo Usuario"

<div class="d-sm-flex align-items-center justify-content-between mb-4">
    <h1 class="h3 mb-0 text-gray-800">Listado de Usuarios</h1>
    <button id="btnNuevoUsuario" class="btn btn-primary btn-icon-split">
        <span class="icon text-white-50"><i class="fas fa-plus"></i></span>
        <span class="text">Nuevo Usuario</span>
    </button>
</div>

<div class="row mb-4">
    <div class="col-12 col-md-6">
        <input type="text" id="inputBuscar" class="form-control"
               placeholder="Buscar por DNI, nombre, email o teléfono...">
    </div>
</div>

Tabla vacía + contenedor de paginación

<table class="table table-bordered" id="tablaUsuarios">
    <thead>
        <tr>
            <th>#</th><th>Nombre Completo</th><th>DNI</th>
            <th>Fecha Nacimiento</th><th>Teléfono</th>
            <th>Email</th><th>Acciones</th>
        </tr>
    </thead>
    <tbody><!-- Las filas se cargan con AJAX --></tbody>
</table>
<div id="paginacion"></div>

Modales de Crear / Editar (mismo patrón)

  • Bootstrap 4 modal con data-toggle="modal".
  • Selector de avatar con radio buttons que envían el nombre del SVG.
  • Campos con required, minlength y maxlength HTML5 (primera línea de defensa cliente).
  • Para editar, hay un <input type="hidden" id="edit_id"> que guarda el ID.
Punto a destacar en la entrevista: reutilicé el mismo HTML para crear y editar (dos modales separados pero idéntico esquema). En un refactor lo unificaría en un solo modal cuyo estado lo gestione JavaScript.

6.2 · js/usuarios.js · cliente jQuery

El archivo orquesta toda la interacción. Lo encapsulé en $(document).ready para evitar contaminar el ámbito global y para asegurarme de que el DOM existe.

Estado mínimo

let paginaActual = 1;
let terminoBusqueda = '';

Sólo dos variables porque la fuente de verdad es la base de datos. No mantengo el array de usuarios en memoria (cada cambio fuerza un re-fetch). Es simple y robusto a costa de algunas requests extras.

Función central: cargarUsuarios(page)

function cargarUsuarios(page = 1) {
    paginaActual = page;
    $.ajax({
        url: 'php/api.php',
        type: 'POST',
        data: { action: 'listar', page: page, search: terminoBusqueda },
        dataType: 'json',
        success: function(respuesta) {
            let filas = '';
            respuesta.data.forEach(function(usuario, index) {
                const numeroFila = (paginaActual - 1) * 10 + index + 1;
                filas += `<tr>...</tr>`;
            });
            $('#tablaUsuarios tbody').html(filas);
            generarPaginacion(respuesta.total_pages, paginaActual);
        }
    });
}

Event delegation

Los botones de la tabla y de la paginación se crean dinámicamente. Por eso enlazo eventos a document con delegación, no directamente al botón:

$(document).on('click', '.btn-editar', function() { ... });
$(document).on('click', '#btnAnterior', function() { ... });
$(document).on('click', '#btnSiguiente', function() { ... });
Concepto clave para la entrevista — Event Delegation: Si haces $('.btn-editar').click(...) directamente, sólo se enganchan los botones que existen en ese instante. Cuando AJAX repinta la tabla, los nuevos botones no responden. Por eso engancho al contenedor (document) y filtro con el selector. Esto se llama delegación de eventos.

Buscador en tiempo real

$('#inputBuscar').on('keyup', function() {
    terminoBusqueda = $(this).val().trim();
    cargarUsuarios(1);
});
Mejora honesta que admitir: esto dispara una petición por cada tecla. Lo correcto sería un debounce de ~300 ms para esperar a que el usuario pare de teclear. Lo verás en la sección 10.

6.3 · php/api.php · backend

Patrón front controller casero: un único archivo, un parámetro action, un if/else if que enruta a cada operación.

Cabecera y conexión

header('Content-Type: application/json; charset=utf-8');

$conn = new mysqli('localhost', 'root', '', 'pruebatecnica');
$conn->set_charset("utf8mb4");

$action = isset($_POST['action']) ? $_POST['action'] : '';
  • UTF-8 tanto en cabecera como en la conexión (importante para tildes/ñ).
  • mysqli en vez de PDO: válido para la prueba, pero PDO sería más portable.

Validación de DNI español

function esDniValido($dni) {
    $dni = strtoupper(trim($dni));
    if (strlen($dni) !== 9) return false;
    if (!preg_match('/^[0-9]{8}[A-Z]$/', $dni)) return false;
    return true;
}
Mejora que puedes ofrecer si te lo preguntan: el DNI español tiene una letra calculable a partir de los 8 dígitos (módulo 23 sobre la tabla TRWAGMYFPDXBNJZSQVHLCKE). La regex de arriba sólo valida el formato, no la letra real. Una validación completa sería:
function letraDni(int $numero): string {
    $letras = 'TRWAGMYFPDXBNJZSQVHLCKE';
    return $letras[$numero % 23];
}
function esDniValido(string $dni): bool {
    $dni = strtoupper(trim($dni));
    if (!preg_match('/^(\d{8})([A-Z])$/', $dni, $m)) return false;
    return letraDni((int)$m[1]) === $m[2];
}

Acción listar · paginación + búsqueda

$page   = isset($_POST['page']) ? (int)$_POST['page'] : 1;
$limit  = 10;
$offset = ($page - 1) * $limit;
$search = trim($_POST['search'] ?? '');

$where = '';
if ($search !== '') {
    $searchEsc = $conn->real_escape_string($search);
    $where = "WHERE nombre LIKE '%$searchEsc%' OR email LIKE '%$searchEsc%'
              OR dni LIKE '%$searchEsc%' OR telefono LIKE '%$searchEsc%'";
}

$totalRegistros = $conn->query("SELECT COUNT(*) as total FROM users $where")
                       ->fetch_assoc()['total'];
$totalPaginas   = ceil($totalRegistros / $limit);

$sql = "SELECT id, nombre, dni, fecha_nacimiento, telefono, email, foto
        FROM users $where ORDER BY id DESC LIMIT $offset, $limit";
$result = $conn->query($sql);
  • Casteo (int)$_POST['page'] antes de inyectarlo — protección contra SQL injection en ese campo.
  • real_escape_string para el término de búsqueda.
  • Hago dos queries: una para el total (paginación) y otra para los 10 registros visibles.

Respuesta JSON estándar

echo json_encode([
    'data'        => $usuarios,
    'total'       => $totalRegistros,
    'page'        => $page,
    'total_pages' => $totalPaginas
]);

Acciones crear y actualizar

Idénticas salvo por el SQL (INSERT vs UPDATE). Ambas:

  1. Comprueban que nombre, dni y fecha_nacimiento no están vacíos.
  2. Validan formato de DNI.
  3. Si hay teléfono, comprueban longitud mínima de 9.
  4. Escapan cada string con real_escape_string.
  5. Devuelven {success:true, message:'...'} o {error:'...'}.

7 · Decisiones técnicas y por qué

¿Por qué un único api.php con action en vez de varios archivos?
Para mantener la lógica de conexión y la cabecera Content-Type centralizadas en un solo sitio (DRY). Hacer list.php, create.php, update.php habría duplicado código. En un proyecto real usaría un router (Slim, Symfony) o reescritura URL, pero para una prueba esto es lo más claro y rápido.
¿Por qué POST para listar en vez de GET?
Honestamente, fue por uniformidad: así todas las llamadas usan el mismo patrón POST + action. Lo correcto en una API REST es GET para lecturas (idempotentes, cacheables) y POST/PUT/DELETE para escrituras. Si me lo preguntan, lo digo tal cual: "Lo simplifiqué a un solo verbo; en REST puro usaría GET /users?page=2&search=...".
¿Por qué SB Admin 2?
El PDF pedía expresamente "podéis descargar un template de bootstrap desde el apartado Material". SB Admin 2 es uno de los más usados (gratuito, Bootstrap 4, MIT), tiene tabla, sidebar, modales y formularios ya estilados. Me permitió centrarme en la lógica del backend en lugar de pelearme con CSS.
¿Por qué jQuery y no Fetch/Vanilla JS moderno?
Porque el enunciado lo exige textualmente: "se empleará ajax con jquery". Si me lo permiten, hoy lo haría con fetch() y async/await, que es nativo y no requiere librería externa.
¿Por qué validas en cliente Y en servidor?
Regla de oro: el cliente valida para UX (feedback inmediato), el servidor valida para seguridad. Un atacante puede saltarse el HTML/JS con Postman o curl, así que la validación real siempre está en el backend. El required y minlength del HTML son sólo una conveniencia.
¿Por qué avatares fijos en vez de subida de fotos?
La prueba no pedía gestión de ficheros, y subir imágenes implica validar MIME-type, tamaño, seguridad contra path traversal, etc. Con 4 SVGs predefinidos cumplo visualmente sin abrir esa lata de complejidad. Si me lo piden, sé cómo hacerlo con $_FILES y move_uploaded_file.
¿Por qué ORDER BY id DESC?
Para que los usuarios recién creados aparezcan arriba. Es la UX esperada cuando acabas de añadir alguien (lo ves inmediatamente). Como id es AUTO_INCREMENT, DESC = orden inverso de creación.

8 · Validaciones implementadas

CampoCliente (HTML)Servidor (PHP)
Nombre required empty($nombre) + real_escape_string
DNI required minlength=9 maxlength=9 Regex /^[0-9]{8}[A-Z]$/
Fecha nacimiento type=date required empty($fecha_nacimiento) (formato lo garantiza el input date)
Teléfono minlength=9 maxlength=20 Si no está vacío → strlen < 9 rechaza
Email type=email (no validado explícitamente — mejora pendiente)
Foto Radio button preseleccionado Fallback a undraw_profile.svg si llega vacío
Lagunas que reconoceré honestamente en la entrevista:
  • El email no se valida server-side. filter_var($email, FILTER_VALIDATE_EMAIL) sería el fix.
  • El DNI sólo se valida en formato, no la letra de control.
  • La fecha no comprueba que sea anterior a hoy ni rango plausible (1900<año<hoy).
  • No hay control de DNI duplicado: si lo añado, devolveré 409 Conflict.

9 · Seguridad · vulnerabilidades y cómo las cerraría

completamente honesto en este punto si te preguntan. Reconocer fallos y proponer la solución vale más que pretender que el código es perfecto. Demuestra madurez técnica.

9.1 · SQL Injection · riesgo bajo pero existe

Uso real_escape_string, lo cual protege, pero no es la mejor práctica. Lo correcto son prepared statements:

// Lo que hago ahora
$nombreEsc = $conn->real_escape_string($nombre);
$sql = "INSERT INTO users (nombre, dni, ...) VALUES ('$nombreEsc', ...)";

// Lo que debería hacer
$stmt = $conn->prepare("INSERT INTO users (nombre, dni, fecha_nacimiento, telefono, email, foto)
                        VALUES (?, ?, ?, ?, ?, ?)");
$stmt->bind_param('ssssss', $nombre, $dni, $fechaNacimiento, $telefono, $email, $foto);
$stmt->execute();

Por qué importa: con prepared statements el driver envía los valores por separado del SQL, por lo que nunca se pueden interpretar como sintaxis. real_escape_string depende de que no se olvide nunca y de que el charset esté bien configurado.

9.2 · XSS · vulnerabilidad real en el frontend

En usuarios.js concateno el HTML así:

filas += `<td>${usuario.nombre}</td>`;

Si un usuario malicioso (o un compañero) crea uno con nombre <script>alert(1)</script>, el script se ejecuta en el navegador de todos los que vean el listado.

Fix: escapar antes de insertar:

function escapeHtml(s) {
    return String(s ?? '').replace(/[&<>"']/g, c => ({
        '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
    }[c]));
}
// uso:
filas += `<td>${escapeHtml(usuario.nombre)}</td>`;

O usar $.text() en vez de $.html(), o construir nodos con document.createElement.

9.3 · CSRF · no hay token

Cualquier página externa podría hacer POST a php/api.php con cookies del usuario. Fix: token CSRF en sesión, enviado en cada AJAX por header X-CSRF-Token y verificado en PHP.

9.4 · No hay autenticación

Cualquiera con acceso a la URL puede listar, editar y borrar. La prueba no lo pedía, pero en producción haría:

  • Login con sesiones PHP o JWT.
  • Verificación de sesión al inicio de api.php antes de procesar la acción.
  • Roles (admin/usuario) si fueran necesarios.

9.5 · Credenciales hardcodeadas

$username = 'root';
$password = '';

Problema: si subo esto a Git con credenciales reales, fuga. Fix: variables de entorno (getenv('DB_PASS')) o un archivo config.php excluido por .gitignore.

9.6 · Errores genéricos

Hoy devuelvo {"error":"Error creating user"} sin detalle. Bien para no filtrar información a un atacante, pero malo para debug. Solución: loguear el error real (error_log) y devolver un mensaje genérico al cliente.

9.7 · Cierre rápido del checklist OWASP Top 10

OWASPEstadoMitigación
A01 Broken Access ControlvulnerableAñadir auth
A03 InjectionparcialPrepared statements
A05 MisconfigurationparcialQuitar credenciales hardcodeadas
A07 Auth FailuresvulnerableLogin obligatorio
XSS (era A07 en 2017)vulnerableEscape en render

10 · Optimización y mejoras técnicas

Hoy

Cada tecla en el buscador → 1 petición HTTP.

Si tecleo "antonio" → 7 peticiones, las 6 primeras se desperdician.

Con debounce

Espero 300 ms tras la última tecla y disparo una sola petición.

function debounce(fn, ms = 300) {
    let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
}
$('#inputBuscar').on('keyup', debounce(function() {
    terminoBusqueda = $(this).val().trim();
    cargarUsuarios(1);
}, 300));

10.2 · Índices en BBDD para el buscador

El LIKE '%texto%' impide usar índices B-tree (porque empieza por comodín). Para tablas pequeñas no importa, pero a partir de ~50k registros se nota. Soluciones:

  • Fulltext index: ALTER TABLE users ADD FULLTEXT(nombre, email) + MATCH(...) AGAINST(...).
  • Migrar a buscador externo: Elasticsearch / Meilisearch para fuzzy search.

10.3 · Loaders y notificaciones decentes

Hoy uso alert(), que bloquea el hilo y es feísimo. Mejora:

// Mostrar spinner mientras carga
$('#tablaUsuarios tbody').html('<tr><td colspan="7" class="text-center"><i class="fa fa-spinner fa-spin"></i> Cargando...</td></tr>');

// En vez de alert(), un toast (SB Admin 2 trae Bootstrap toast):
function toast(msg, type='success') {
    $('#toastContainer').append(`<div class="toast bg-${type}">${msg}</div>`);
}

10.4 · Validación reactiva en el formulario

Hoy el usuario sólo descubre que el DNI es inválido al enviar. Lo ideal es validar on blur del campo y marcar en rojo en cuanto pierde foco.

10.5 · Paginación más rica

Hoy sólo tengo "Anterior / Siguiente". En 30 segundos puedo añadir botones numerados:

function generarPaginacion(total, actual) {
    let html = `<button id="btnAnterior" ${actual<=1?'disabled':''}>«</button>`;
    for (let i = 1; i <= total; i++) {
        html += `<button class="btn-page ${i===actual?'active':''}" data-page="${i}">${i}</button>`;
    }
    html += `<button id="btnSiguiente" ${actual>=total?'disabled':''}>»</button>`;
    $('#paginacion').html(html);
}

10.6 · Una sola query para listar + total

Hoy hago dos queries: COUNT(*) y SELECT. En MySQL puedo combinarlas con SQL_CALC_FOUND_ROWS (legacy) o con una subquery, aunque para tablas pequeñas la diferencia es despreciable y la legibilidad del código actual es mejor.

10.7 · Caching del listado

Para tablas con muchos hits y pocos cambios, cachear el JSON en Redis con TTL de 30s reduce drásticamente las queries.

11 · Cómo lo refactorizaría hoy (versión moderna)

11.1 · Backend con prepared statements + PDO

// db.php
function db(): PDO {
    static $pdo = null;
    if ($pdo === null) {
        $pdo = new PDO(
            'mysql:host=localhost;dbname=pruebatecnica;charset=utf8mb4',
            getenv('DB_USER') ?: 'root',
            getenv('DB_PASS') ?: '',
            [
                PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                PDO::ATTR_EMULATE_PREPARES   => false,
            ]
        );
    }
    return $pdo;
}

// ListUsers.php
function listUsers(int $page, string $search): array {
    $limit = 10; $offset = ($page - 1) * $limit;
    $params = [];
    $where = '';
    if ($search !== '') {
        $where = "WHERE nombre LIKE :s OR email LIKE :s OR dni LIKE :s OR telefono LIKE :s";
        $params[':s'] = "%$search%";
    }
    $total = db()->prepare("SELECT COUNT(*) FROM users $where");
    $total->execute($params);
    $totalRows = (int) $total->fetchColumn();

    $stmt = db()->prepare("SELECT id, nombre, dni, fecha_nacimiento, telefono, email, foto
                          FROM users $where ORDER BY id DESC LIMIT :limit OFFSET :offset");
    foreach ($params as $k => $v) $stmt->bindValue($k, $v);
    $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
    $stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
    $stmt->execute();

    return ['data' => $stmt->fetchAll(), 'total' => $totalRows,
            'page' => $page, 'total_pages' => (int) ceil($totalRows / $limit)];
}

11.2 · Cliente con fetch + async/await (sin jQuery)

async function cargarUsuarios(page = 1) {
    const params = new URLSearchParams({ action: 'listar', page, search: terminoBusqueda });
    const res = await fetch('php/api.php', { method: 'POST', body: params });
    const data = await res.json();
    if (data.error) return mostrarError(data.error);
    renderTabla(data.data);
    renderPaginacion(data.total_pages, data.page);
}

11.3 · REST verbos correctos

OperaciónHoyREST correcto
ListarPOST api.php action=listarGET /api/users?page=1&q=...
DetallePOST api.php action=obtenerGET /api/users/{id}
CrearPOST api.php action=crearPOST /api/users
EditarPOST api.php action=actualizarPUT /api/users/{id}
Borrar(no implementado)DELETE /api/users/{id}

11.4 · Stack moderno alternativo

  • Backend: Laravel/Lumen, Symfony, Slim, o Node.js (Express/Fastify) si TypeScript.
  • Frontend: Vue 3 o React + TypeScript, con un componente UserList y UserForm.
  • Estado: React Query / TanStack Query para cache, refetch automático y optimistic updates.
  • UI: Mantine, shadcn/ui o Material — más profesional que un template estático.
  • BBDD: sigue siendo MySQL o PostgreSQL. ORM (Eloquent/Prisma) para tipado.

12 · Banco de preguntas probables y respuestas

Cuéntame qué has hecho en la prueba.
Una mini-app de gestión de usuarios con tres operaciones: listar (con paginación y búsqueda), crear y editar. El front es Bootstrap (SB Admin 2) con jQuery y AJAX, el backend es PHP devolviendo JSON, y la persistencia es MySQL. Todo testado en XAMPP en local. La tabla principal es users con id, nombre, dni, fecha_nacimiento, telefono, email, foto.
¿Qué es AJAX?
Asynchronous JavaScript and XML. Es la técnica que permite al navegador hacer peticiones HTTP al servidor sin recargar la página. Hoy el XML es anecdótico: usamos JSON. En jQuery se hace con $.ajax; nativamente con fetch() o XMLHttpRequest.
¿Qué es REST?
Representational State Transfer. Es un estilo arquitectónico para diseñar APIs que se apoya en HTTP y sus verbos (GET, POST, PUT, DELETE, PATCH). Cada recurso tiene una URL única (/users/42) y el verbo define la operación. Es stateless: cada petición lleva toda la info necesaria.
¿Cómo evitas inyección SQL?
En la prueba uso real_escape_string y casteo a (int) los IDs/páginas. La mejor práctica son prepared statements (PDO o mysqli con bind_param), porque separan el SQL de los datos en el protocolo del driver. Es lo que pondría en producción.
¿Qué es XSS y cómo lo previenes?
Cross-Site Scripting: meter código JavaScript dentro de datos que luego el navegador interpreta. Se previene escapando cualquier dato del usuario antes de meterlo en HTML (&&amp;, <&lt;, etc.). En jQuery, usar .text() en vez de .html(). Reconozco que en mi prueba inyecto los nombres con plantilla literal, lo cual es vulnerable — lo corregiría con una función escapeHtml.
¿Qué diferencia hay entre POST y GET?
GET es para leer (idempotente, cacheable, parámetros visibles en la URL). POST es para crear o modificar (no idempotente por defecto, body separado, no cacheable). En mi prueba uso POST para todo por uniformidad, pero en REST GET sería el verbo correcto para listar.
¿Qué hace $(document).ready() y por qué?
Espera a que el DOM esté completamente parseado antes de ejecutar el código. Sin él, si el script está antes del HTML que manipula, fallaría porque los elementos aún no existen. Hoy en navegadores modernos basta con poner el <script> al final del body o con defer.
¿Qué es event delegation?
En vez de enganchar un evento a un elemento concreto, lo enganchas a un contenedor padre estable y filtras por selector. Así, los elementos añadidos dinámicamente (por AJAX) también responden. En la prueba lo uso para los botones .btn-editar y los de paginación, que se recrean con cada $.html().
¿Cómo escalarías esto a 1 millón de usuarios?
  1. Migrar a prepared statements y validación robusta (sí o sí).
  2. Índice en dni y un índice fulltext en nombre, email para el buscador.
  3. Paginación por cursor (WHERE id < ?) en vez de OFFSET, que se vuelve lento con páginas profundas.
  4. Cache de Redis para las queries más comunes.
  5. Compresión gzip + HTTP/2.
  6. Servidor PHP en un pool con balanceador (HAProxy/Nginx) y MySQL en réplica master/slave para escalar lecturas.
  7. Buscador externo (Meilisearch / Elastic) si el LIKE deja de rendir.
¿Por qué utf8mb4 y no utf8?
El utf8 de MySQL es un alias engañoso: sólo soporta hasta 3 bytes por carácter, por lo que no soporta emojis ni algunos caracteres chinos. utf8mb4 es el UTF-8 real, hasta 4 bytes. Siempre utf8mb4.
¿Cómo testarías esto?
  • Backend: PHPUnit. Test unitarios para esDniValido() (con casos buenos y malos), tests de integración contra una BBDD de test que se reinicia con fixtures.
  • Frontend: Jest + Testing Library, o Cypress para tests end-to-end (login, crear usuario, buscar, paginar).
  • API: Postman collections o tests con guzzlehttp/guzzle en PHP.
¿Y si te pidiera añadir "Borrar usuario" ahora mismo?
  1. Añado un botón btn-borrar en cada fila con data-id.
  2. Listener delegado al document con confirmación (SweetAlert idealmente, confirm() mínimo).
  3. Nueva acción borrar en api.php con DELETE FROM users WHERE id = ? (prepared).
  4. Tras éxito, recargo el listado (cargarUsuarios(paginaActual)).
  5. Plus: soft delete con una columna deleted_at en vez de borrar físico, para poder restaurar.
¿Diferencia entre mysqli y PDO?
mysqli sólo habla MySQL. PDO es una abstracción sobre múltiples drivers (MySQL, PostgreSQL, SQLite...). PDO es más portable y tiene una API más limpia para prepared statements. mysqli ofrece algunos métodos específicos de MySQL ligeramente más rápidos. En proyectos nuevos suelo usar PDO.
¿Por qué Bootstrap 4 y no Bootstrap 5 o Tailwind?
El template gratuito SB Admin 2 estaba en Bootstrap 4 y ya tenía sidebar, tabla y modales listos. Para una prueba contrarreloj, era la elección con mejor ratio resultado/tiempo. En un proyecto nuevo iría con Bootstrap 5 (que ya no necesita jQuery) o Tailwind si necesito UI muy custom.
¿Conoces TypeScript?
Sí. TS añade tipado estático sobre JavaScript, lo cual detecta errores en compilación en vez de en runtime. Hoy es prácticamente el estándar para proyectos serios en React/Vue. Si refactorizase la prueba en JS moderno, lo haría en TS y definiría una interface Usuario compartida cliente-servidor (con OpenAPI o un generador).
Háblame de Git.
Uso Git diariamente. Flujo habitual: feature branches, commits atómicos con mensajes claros (Conventional Commits si el equipo lo usa), pull requests con review, merge a main. En la prueba no inicialicé repo, pero en un proyecto real haría git init desde el minuto cero, con un .gitignore que excluya vendor/, node_modules/ y archivos con credenciales.
¿Qué es CORS y cuándo aparece?
Cross-Origin Resource Sharing. Es la política del navegador que impide que JavaScript de un dominio haga peticiones AJAX a otro dominio salvo que el servidor lo autorice con cabeceras Access-Control-Allow-Origin. En mi prueba no hay CORS porque cliente y servidor están en el mismo origen (localhost). Aparecería si separase el front en otro dominio.

13 · Cómo presentar el código en vivo

Si te piden enseñar la aplicación, lleva este guion mental para no improvisar.
  1. Arrancar XAMPP: Apache + MySQL en verde. Abrir http://localhost/pruebatecnica/tables.html.
  2. Mostrar el listado: 10 registros, paginación abajo, búsqueda arriba.
  3. Buscar: teclear "antonio" y comentar: "Lo correcto sería un debounce de 300 ms; lo añadiría en producción".
  4. Paginar: ir a página 2, mostrar que la numeración de la primera columna sigue correcta ((página-1)*10 + index + 1).
  5. Editar: abrir un usuario, cambiar el teléfono, guardar. Mostrar que se actualiza sin recargar.
  6. Crear: intentar guardar con DNI inválido para enseñar la validación.
  7. Abrir DevTools → Network: repetir una acción y mostrar el JSON de ida y vuelta. Esto demuestra que entiendes lo que pasa "por debajo".
  8. Abrir el código: abrir api.php en la sección de validación de DNI y explicar la regex.
  9. Cerrar con una mejora: "Lo siguiente que añadiría sería X" (debounce, prepared statements o auth). Demuestra visión.

14 · Cheatsheet · lo último que repasarás

Archivos modificados

  • tables.html
  • js/usuarios.js
  • php/api.php

BBDD

  • db: pruebatecnica
  • tabla: users
  • charset: utf8mb4

Acciones API

  • listar (page, search)
  • obtener (id)
  • crear (datos)
  • actualizar (id, datos)

Validaciones

  • DNI: regex /^\d{8}[A-Z]$/
  • Obligatorios: nombre, dni, fecha
  • Teléfono mín. 9

3 mejoras que siempre mencionar

  1. Prepared statements (PDO).
  2. Escape XSS en el render.
  3. Debounce del buscador.

3 conceptos que tienes que dominar

  1. Event delegation.
  2. Por qué validar en cliente y servidor.
  3. Idempotencia de los verbos HTTP.
Mentalidad para la entrevista:
  • Si no sabes algo, dilo y explica cómo lo investigarías. Es la mejor respuesta posible.
  • Cuando expliques código, habla del por qué más que del qué.
  • Las mejoras que reconoces voluntariamente antes de que te las señalen suman muchísimo.
  • Pregunta tú también: por el equipo, el stack real, los retos de la posición. Demuestra interés.

Mucha suerte mañana. Estás preparado.