🚨 ¡Nueva review! ✨ Mi ratón favorito para programar: el Logitech MX Master 3S . ¡Échale un ojo! 👀

Git Hooks que todo equipo PHP debería tener en cuenta

Detectar el var_dump olvidado, la AWS key filtrada y el commit mal escrito antes de que llegue a la review

Escrito por domin el 12 de julio de 2026

Un developer revisa una PR y después de unas pocas rondas de feedback, finalmente la aprueba y la mergea. Su feedback quedaría así:

  • "Te dejaste un var_dump en OrderService.php." Push fix.
  • "La rama se llama cosas-de-juan, renómbrala siguiendo la convención." Push fix.
  • "Los mensajes de commit deberían seguir Conventional Commits." Rebase, force push.
  • "Hay un parse error en la migración que claramente no has corrido en local." Push fix.
  • "Y el composer.lock no está actualizado aunque bumpeaste la versión en composer.json." Push fix, por fin verde.

Todas se podrían haber detectado de forma automática con un script antes de que se hiciese el primer commit. Pero no, hemos perdido tiempo muy valioso de nuestro equipo revisando y detectando y notificando fallos que podrían haberse evitado.

Para esto son los . Una utilidad muy buena que tienen son detectar de forma simple esta clase de errores tontucos que nos hacen perder tiempo.

TL;DR rapidito

🎯 Los hooks útiles son los humildes

No los que imponen arquitectura. Los que cazan errores triviales antes de que lleguen a la cola del reviewer.

⚡ php -l cuesta 50ms

Un pre-commit con el lint de PHP elimina toda la clase de PRs del tipo "se me olvidó probarlo en local".

🐛 Caza el var_dump y el dd

Una regex en el diff staged borra del mapa el comentario de review más típico en proyectos PHP.

👥 Compártelos en el equipo

captainhook, lefthook o un script de instalación. .git/hooks/ a pelo va bien en solitario; en equipo necesitas algo gestionado.


Hook 1: comprobar la sintaxis de PHP (pre-commit)

El flag -l (lint) de PHP parsea un fichero y reporta errores de sintaxis sin ejecutarlo. Pasándola por cada PHP staged antes del commit se acaba el romper el parser y meter cosas rotas en producción.

#!/bin/bash
# .git/hooks/pre-commit
set -e

STAGED_PHP=$(git diff --cached --name-only --diff-filter=ACMR | grep '\.php$' || true)
[ -z "$STAGED_PHP" ] && exit 0

ERRORS=0
for FILE in $STAGED_PHP; do
    if ! php -l "$FILE" > /dev/null 2>&1; then
        echo "Error de sintaxis en $FILE:"
        php -l "$FILE" || true # sin el || true, set -e corta el bucle en el primer error
        ERRORS=$((ERRORS + 1))
    fi
done

if [ $ERRORS -gt 0 ]; then
    echo ""
    echo "$ERRORS fichero(s) con errores de sintaxis. Commit bloqueado."
    exit 1
fi

El --diff-filter=ACMR filtra por Añadidos, Copiados, Modificados y Renombrados, saltándose los Deletes. El hook solo mira ficheros staged, no el repo entero, así que en un proyecto de 10.000 ficheros se ejecuta en menos de un segundo.

Consejo

Pruébalo: crea un fichero PHP con un error y lánzale php -l:

echo '<?php class Foo {' > roto.php
php -l roto.php
# PHP Parse error: Unclosed '{' on line 1 (salida de PHP 8.4)

Es el hook que se amortiza solo en la primera vez que alguien se olvida de probar una migración en local. El parse error se pilla al hacer commit y no unos minutos después en CI, no cuando un compañero hace pull y se encuentra una rama rota.


Hook 2: detectar debug olvidado (pre-commit)

Otro caso típico de review es detectar y abrir threads por que nos hemos dejado código debug tipo var_dump . Otro hook lo puede detectar fácil y evitar que pushee al repo:

#!/bin/bash
# .git/hooks/pre-commit (extensión)
set -e

STAGED_PHP=$(git diff --cached --name-only --diff-filter=ACMR | grep '\.php$' || true)
[ -z "$STAGED_PHP" ] && exit 0

PATTERNS='var_dump\(|\bdd\(|dump\(|print_r\(|xdebug_break\('

FOUND=$(git diff --cached --diff-filter=ACMR -U0 -- $STAGED_PHP \
    | grep -E "^\+[^+]" \
    | grep -E "$PATTERNS" || true)

if [ -n "$FOUND" ]; then
    echo "Debug olvidado en los cambios staged:"
    echo "$FOUND" | head -20
    echo ""
    echo "Quítalo, o usa 'git commit --no-verify' si es intencionado."
    exit 1
fi

El hook mira las líneas añadidas en el diff, no el contenido actual del fichero. La regex ^\+[^+] matchea líneas que empiezan con + pero no con +++ (la cabecera del diff). Así, un var_dump que llevaba años en un fichero que no has tocado no dispara el hook, solo las líneas nuevas o modificadas.

El xdebug_break() pilla los breakpoints programáticos de Xdebug que algunos usan al debuggear. Y el \b delante de dd\( importa: sin él, cualquier ->add( legítimo (un DateTime, una colección) dispararía el hook. Añade o quita patrones según las convenciones de tu equipo: en Laravel igual te interesa meter \Illuminate\Support\Facades\Log::debug o dump() (Symfony VarDumper); si usáis ray() de Spatie, mételo también.

Nota

La regex no intenta cubrir el 100% de casos. Código que de verdad necesite print_r (logging en producción muy concreto) tira de --no-verify o envuelve la llamada de forma que no matchee. Es para el caso del 95%, no para el del 100%.


Hook 3: detección de secretos (pre-commit)

Este es el hook que previene toda una categoría de marrones catastróficos. Una committeada, o un token de OpenAI, aunque la revertas dos segundos después, queda en el historial de git para siempre y hay que rotarla. El coste de rotar va de pereza a caída en producción. Prevenir el commit es dramáticamente más barato.

#!/bin/bash
# .git/hooks/pre-commit (extensión)
set -e

STAGED=$(git diff --cached --name-only --diff-filter=ACMR || true)
[ -z "$STAGED" ] && exit 0

PATTERNS=(
    'AKIA[0-9A-Z]{16}'                    # AWS access key
    'aws_secret_access_key\s*=\s*[A-Za-z0-9/+=]{40}'
    '\bsk-[A-Za-z0-9_-]{20,}'             # OpenAI (sk-, sk-proj-) / Anthropic (sk-ant-)
    'AIza[0-9A-Za-z_-]{35}'               # Google API key
    'ghp_[A-Za-z0-9]{36}'                 # GitHub PAT
    'xox[baprs]-[A-Za-z0-9-]+'            # Slack tokens
    'BEGIN (RSA |DSA |EC |OPENSSH )?PRIVATE KEY'
    '"password"\s*:\s*"[^"]{8,}"'         # campos password en JSON
)

FOUND=""
for PATTERN in "${PATTERNS[@]}"; do
    MATCH=$(git diff --cached --diff-filter=ACMR -U0 -- $STAGED \
        | grep -E "^\+[^+]" \
        | grep -E "$PATTERN" || true)
    [ -n "$MATCH" ] && FOUND="${FOUND}${MATCH}\n"
done

if [ -n "$FOUND" ]; then
    echo "Posibles secretos en los cambios staged:"
    echo -e "$FOUND" | head -10
    echo ""
    echo "Si es un falso positivo, usa 'git commit --no-verify'."
    echo "Mejor aún: rota cualquier secreto real que casi acaba en el repo."
    exit 1
fi

Los patrones son estrechos a propósito porque así matchean el formato concreto de cada tipo de credencial. Un patrón más ancho (cualquier string de 32 caracteres en base64) pillaría más secretos pero te daría falsos positivos en hashes, IDs, y contenido que podría parecerse a una key.

Para un escaneo más serio existen herramientas dedicadas como , trufflehog o detect-secrets: tienen cientos de patrones y muchísimos menos falsos positivos. Los equipos que tocan infra sensible deberían usar las dos cosas: este hook para feedback rápido en local y un scanner dedicado en CI para escanear a fondo.

Peligro

Si el hook se ha disparado con una key real, rótala ya. No basta con quitarla del staging. La key estuvo en disco, igual está en los ficheros recientes del IDE, igual la pegaste en un chat. Trata el aviso del hook como un susto, no como un acierto limpio.


Hook 4: composer.json y composer.lock sincronizados (pre-commit)

El fallo de CI más cansino del mundo: la build casca porque composer install --no-dev produce un lock distinto al que está committeado. Causa raíz: alguien tocó el composer.json (añadió una dependencia, bumpeó una versión) pero no corrió composer update ni committeó el lock actualizado.

#!/bin/bash
# .git/hooks/pre-commit (extensión)
set -e

STAGED=$(git diff --cached --name-only --diff-filter=ACMR || true)

JSON_STAGED=$(echo "$STAGED" | grep -c "^composer\.json$" || true)
LOCK_STAGED=$(echo "$STAGED" | grep -c "^composer\.lock$" || true)

if [ "$JSON_STAGED" -gt 0 ] && [ "$LOCK_STAGED" -eq 0 ]; then
    echo "composer.json está staged pero composer.lock no."
    echo "Corre 'composer update' o 'composer install' y añade el lock al staging."
    exit 1
fi

if [ "$JSON_STAGED" -eq 0 ] && [ "$LOCK_STAGED" -gt 0 ]; then
    echo "composer.lock está staged pero composer.json no."
    echo "¿Querías actualizar los dos? Usa --no-verify si es una actualización de seguridad."
    exit 1
fi

El hook comprueba las dos direcciones: composer.json sin lock es el caso típico, pero el contrario (lock sin json) también pasa, normalmente cuando alguien hace composer update y se traga updates de versiones menores.


Hook 5: bloquear .env y ficheros de claves (pre-commit)

Algo parecido a la detección de secretos pero a otro nivel. Hay nombres de fichero que nunca deberían entrar al repo, da igual lo que tengan dentro.

#!/bin/bash
# .git/hooks/pre-commit (extensión)
set -e

STAGED=$(git diff --cached --name-only --diff-filter=ACMR || true)
[ -z "$STAGED" ] && exit 0

# Bloquea .env pero deja pasar .env.example, .env.dist, .env.testing
DANGEROUS=$(echo "$STAGED" | grep -E '(^|/)\.env$' || true)
KEY_FILES=$(echo "$STAGED" | grep -E '\.(pem|key|p12|pfx)$' || true)

if [ -n "$DANGEROUS" ] || [ -n "$KEY_FILES" ]; then
    echo "Bloqueando ficheros que típicamente contienen secretos:"
    [ -n "$DANGEROUS" ] && echo "$DANGEROUS"
    [ -n "$KEY_FILES" ] && echo "$KEY_FILES"
    echo ""
    echo "Añádelos al .gitignore."
    exit 1
fi

La regex (^|/)\.env$ matchea .env y ruta/al/.env pero explícitamente no las variantes con sufijo. .env.example y .env.dist sí queremos que se committeen (son plantillas que dicen qué variables hay que configurar). Ojo con esto: .env.local y .env.production también pasan el filtro, y esos llevan secretos reales. Si tu equipo los usa, amplía la regex.

Las extensiones .pem, .key, .p12, .pfx son típicamente claves privadas de SSL, firma o auth. Si tienes fixtures de tests con claves legítimas, --no-verify y palante. El hook prefiere "bloquear por defecto, escape hatch para excepciones".


Hook 6: marcadores de merge conflict (pre-commit)

Cuando un merge o rebase mete conflictos, el dev los resuelve en el editor. Y a veces (más a menudo de lo que parece) guarda sin quitar todos los marcadores, y luego commitea. El resultado es un fichero con líneas literales <<<<<<< HEAD que PHP no sabe parsear.

#!/bin/bash
# .git/hooks/pre-commit (extensión)
set -e

STAGED=$(git diff --cached --name-only --diff-filter=ACMR || true)
[ -z "$STAGED" ] && exit 0

FOUND=$(git diff --cached --diff-filter=ACMR -U0 -- $STAGED \
    | grep -E "^\+[^+]" \
    | grep -E '^\+(<{7}|={7}$|>{7})' || true)

if [ -n "$FOUND" ]; then
    echo "Marcadores de conflicto encontrados en los cambios staged:"
    echo "$FOUND" | head -10
    echo ""
    echo "Resuelve el conflicto antes de commitear."
    exit 1
fi

El patrón matchea exactamente 7 caracteres consecutivos <, > o = al principio de línea, que es el formato concreto que mete git. Otros bloques de 7 caracteres iguales (un comentario decorativo, ASCII art) no matchean porque van a mitad de línea, no al principio.

El hook 1 (lint de PHP) ya pilla esto indirectamente porque los marcadores rompen el parser, pero este es más rápido (no levanta proceso PHP, solo texto) y da un mensaje más claro. Tenerlos los dos juntos sale gratis y atacan el mismo problema desde ángulos distintos.


Hook 7: ficheros grandes (pre-commit)

Un asset binario committeado por error es un compromiso para siempre. Una vez está en el historial, sacarlo de ahí requiere reescribir el historial y forzar push, una operación destructiva tan disruptiva que la mayoría de equipos no lo hace nunca. El artefacto se queda en el repo para siempre, hinchando los clones y ralentizando todo.

#!/bin/bash
# .git/hooks/pre-commit (extensión)
set -e

MAX_SIZE_KB=500
STAGED=$(git diff --cached --name-only --diff-filter=ACMR || true)
[ -z "$STAGED" ] && exit 0

LARGE_FILES=""
while IFS= read -r FILE; do
    [ -z "$FILE" ] && continue
    [ ! -f "$FILE" ] && continue
    SIZE_KB=$(du -k "$FILE" | cut -f1)
    if [ "$SIZE_KB" -gt "$MAX_SIZE_KB" ]; then
        LARGE_FILES="${LARGE_FILES}${FILE} (${SIZE_KB}KB)\n"
    fi
done <<< "$STAGED"

if [ -n "$LARGE_FILES" ]; then
    echo "Ficheros que pasan del límite de ${MAX_SIZE_KB}KB:"
    echo -e "$LARGE_FILES"
    echo "Plantéate usar Git LFS para assets binarios."
    exit 1
fi

El umbral de 500KB es conservador y pilla la mayoría de commits accidentales. Si tu equipo tiene assets legítimos grandes (datasets de ejemplo, fixtures, imágenes que de verdad pintan estar en el repo), sube el límite o lista rutas específicas como excepción.


Hook 8: validación del nombre de rama (pre-push)

Este es un hook de proceso, no de corrección. Algunos equipos cuidan los nombres de rama por razones de tooling (auto-deploy en release/*, link automático con Jira en fix/JIRA-123), otros no. Para los que sí, bloquear nombres malos es free comparado con renombrar después.

#!/bin/bash
# .git/hooks/pre-push
set -e

BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")

PATTERN='^(main|develop|master|(feature|fix|chore|hotfix|release|refactor|test|docs)/[a-z0-9._-]+)$'

if [[ ! "$BRANCH" =~ $PATTERN ]]; then
    echo "El nombre de rama '$BRANCH' no encaja con los patrones permitidos:"
    echo "  feature/descripcion-corta"
    echo "  fix/issue-123"
    echo "  chore/limpieza-x"
    echo "  hotfix/cosa-urgente"
    echo ""
    echo "Renómbrala con 'git branch -m <nuevo-nombre>'."
    exit 1
fi

Esto va en pre-push y no en pre-commit porque el nombre de rama solo importa al compartirla. Commits locales en una rama con nombre raro no molestan a nadie. Cazarlo al hacer push significa que el dev puede seguir trabajando en cosas-de-paco hasta que esté listo, y entonces renombra a feature/actualizar-inventario una vez.

Consejo

Ajusta el hook a tus necesidades, este es de ejemplo. Hay equipos que exigen ticket (feature/JIRA-1234-nombre), otros solo permiten ciertos prefijos. La estructura del hook se queda igual, solo cambia la regex.


Hook 9: Conventional Commits (commit-msg)

El último hook, y el que más se quejan los devs hasta que se acostumbran. da estructura a los commits y hace que git log sirva de verdad para changelogs, releases y arqueología del historial.

#!/bin/bash
# .git/hooks/commit-msg
set -e

MSG_FILE=$1
MSG=$(head -1 "$MSG_FILE")

# Salta merges, reverts y fixups (git los genera automáticamente)
if echo "$MSG" | grep -qE '^(Merge |Revert |fixup! |squash! )'; then
    exit 0
fi

PATTERN='^(feat|fix|chore|refactor|test|docs|perf|style|build|ci)(\([a-z0-9_-]+\))?!?: .{1,70}$'

if [[ ! "$MSG" =~ $PATTERN ]]; then
    echo "El mensaje de commit no sigue Conventional Commits:"
    echo "  '$MSG'"
    echo ""
    echo "Esperado: type(scope): descripción"
    echo "Ejemplos:"
    echo "  feat(auth): añadir soporte SAML SSO"
    echo "  fix(checkout): manejar tax rates nulos"
    echo "  chore: actualizar dependencias de composer"
    echo ""
    echo "Tipos permitidos: feat, fix, chore, refactor, test, docs, perf, style, build, ci"
    exit 1
fi

Este hook vive en commit-msg, no en pre-commit. La diferencia es que commit-msg corre después de que el mensaje esté compuesto, con el fichero del mensaje como $1. El hook puede leerlo o modificarlo; en este caso solo valida la primera línea.

El límite de 70 caracteres en la descripción sigue la convención del kernel de Linux y de la mayoría de guías de estilo. Hay equipos que prefieren 50: es un parámetro, no una religión. Los merges, reverts y los fixup! de git commit --fixup se saltan porque git les genera el mensaje automáticamente y no siguen la convención (los fixup además desaparecen en el rebase).

Lo que esto desbloquea con el tiempo: git log --oneline pasa a ser autoexplicativo. Herramientas como conventional-changelog generan release notes directamente desde los commits. Bumpear versiones en base al tipo de commit (feat = minor, fix = patch, feat! o BREAKING CHANGE: = major) se vuelve posible. El hook es la puerta de entrada a un ecosistema entero de automatización que depende de mensajes consistentes.


Compartir hooks en el equipo

Todos los hooks de arriba viven en .git/hooks/, que no se versiona con el repo. Cada dev se los tiene que instalar a mano. Para proyectos en solitario va bien, en equipo no.

Tres patrones lo solucionan:

📜 Script de instalación manual

Commiteas los hooks en bin/git-hooks/ y un install.sh los copia o linka a .git/hooks/. Simple, sin dependencias, requiere disciplina.

⚓ captainhook

Gestor PHP-nativo de git hooks. Configurado vía captainhook.json, instalado como dependencia de composer dev. La opción más idiomática para equipos PHP.

⚡ lefthook

Gestor cross-language. lefthook.yml, hooks en paralelo (más rápido). Buen fit para equipos políglotas con varios lenguajes.

Para la mayoría de equipos PHP, captainhook es la recomendación por defecto: se instala vía composer, integra con PHPStan y otras tools PHP de forma nativa, y se configura por repo. Los devs en solitario y los equipos pequeños pueden empezar con .git/hooks/ a pelo más un script de instalación, y migrar a una herramienta cuando crezca el equipo.

Ejemplo mínimo de bin/install-hooks.sh:

#!/bin/bash
# bin/install-hooks.sh — instala los hooks del repo en .git/hooks/
set -e

HOOKS_DIR=".git/hooks"
SOURCE_DIR="bin/git-hooks"

if [ ! -d "$HOOKS_DIR" ]; then
    echo "Esto no parece un repo de git. Aborto."
    exit 1
fi

for HOOK in "$SOURCE_DIR"/*; do
    NAME=$(basename "$HOOK")
    ln -sf "../../$HOOK" "$HOOKS_DIR/$NAME"
    chmod +x "$HOOK"
    echo "Hook instalado: $NAME"
done

echo "Listo. EA, ya tienes los hooks instalados."
Importante

Hazlos ejecutables. Un script de pre-commit sin chmod +x no hace nada en silencio. El script de instalación debería poner los permisos explícitamente. captainhook y lefthook lo hacen solos; los setups manuales hay que cuidarlos.


Errores a evitar

Lo que sí funciona
  • Hooks que corren en menos de 2 segundos
  • Mensajes de error claros que apuntan al fix
  • Solo revisar lo que el commit cambia, no el repo entero
  • Dejar --no-verify disponible como válvula de escape
  • Hooks rápidos en local + checks completos en CI
Lo que mata la disciplina del equipo
  • Hooks que tardan más de 2 segundos (la gente los bypasea)
  • Reformatear código en pre-commit (debug nightmare)
  • Tirar PHPStan completo en pre-commit (eso es trabajo de CI)
  • Atar hooks a una versión de PHP que no tiene todo el equipo
  • Crear culpa social alrededor de --no-verify (la gente se va a la web UI)
Advertencia

El error más común es reformatear código automáticamente en pre-commit (PHP-CS-Fixer con --fix y re-stage). Parece útil pero genera infierno al debuggear porque el código committeado no coincide con lo que el dev vio en local. Corre los formatters en modo check (--dry-run y exit con error si hay diferencias).

Concluyendo

Con una serie de hooks bien planteados y configurados, una PR se convierte en lo que debe ser. Una lectura de cambios en el código en el que podemos hacer focus realmente a lo que importa y así dejar de desviar la atención del reviewer a lo que de verdad importa y necesita criterio humano.

Nos vemos en los bares! 🍻