El problema con el que siempre me topaba
Cada vez que empezaba un proyecto pequeño en PHP, tenía dos malas opciones.
Opción uno: coger un framework completo. Conseguir un ORM cuyo lenguaje de queries tengo que aprender, un motor de plantillas que básicamente es PHP-pero-distinto, un contenedor de servicios, pipelines de middleware, y una estructura de carpetas diseñada por un comité. La mitad de mi tiempo de "aprendizaje" se va en las opiniones del framework, no en mi app de verdad.
Opción dos: ir a PHP crudo del todo. Reconstruir el enrutamiento, reconstruir un envoltorio de base de datos, reconstruir la protección CSRF, reconstruir sesiones — por quinta vez, un poco peor que la anterior, porque voy con prisa.
Quería algo intermedio: la fontanería aburrida resuelta una sola vez, en código que de verdad pueda leer, sin nada oculto debajo.
Eso es Tanuki.
Qué es Tanuki realmente
Un framework PHP MVC ligero con:
- Un router que mapea
'GET /todo/{id}' => 'TodoController@show'— sin anotaciones, sin magia de atributos, solo un array. - Una clase base
Modelque es un envoltorio fino y honesto sobre PDO —TodoModel::where('completed', 0)construye un prepared statement real que podrías escribir tú mismo, solo te ahorra teclear. - Vistas que son solo... archivos PHP.
<?= e($todo['title']) ?>. Sin sintaxis nueva. - Cero ORM. Cero motor de plantillas. Cero contenedor de inyección de dependencias.
Tener libertad total de trabajar según tu forma de programar en vanilla o seguir un patrón MVC ya diseñado básico y sencillo te otorga libertad sin ningún tipo de magia.
Aquí está el flujo completo de una petición, y puedes leer el código fuente real en unos diez minutos:
Navegador → public/index.php → App::run()
│
├── loadEnv()
├── registerAutoloader()
├── configureErrors()
└── dispatch()
│
├── Encuentra ruta exacta o patrón {param}
└── Instancia el Controller → llama método()
│
└── $this->view('nombre', $data)
Eso es todo. Ese es el ciclo de vida completo de una petición.
A quién va dirigido
Si eres del tipo de desarrollador que:
- prefiere escribir SQL directo antes que aprender el query builder de un ORM,
- se molesta cuando un framework hace algo "por ti" que no pediste,
- borra código con más facilidad de la que escribe abstracciones nuevas,
...probablemente te sientas como en casa aquí. Esto no intenta competir con Laravel o Symfony en funcionalidades — es deliberadamente más pequeño en alcance, pensado para gente que quiere un esqueleto MVC real sin el peso.
Un ejemplo real: el CRUD de TODO
El repositorio incluye una lista de tareas completa y funcional como referencia de aprendizaje:
class TodoController extends Controller
{
public function store(): void
{
if (!csrf_verify($this->request->post('_token'))) {
$this->flash('error', 'Tu sesión expiró. Inténtalo de nuevo.');
$this->redirect('/todo/create');
}
$title = $this->request->post('title');
if (empty($title)) {
keep_old(['title' => $title]);
$this->flash('error', 'El título es obligatorio.');
$this->redirect('/todo/create');
}
TodoModel::create(['title' => $title, 'completed' => 0]);
$this->flash('success', '¡Tarea creada!');
$this->redirect('/todo');
}
}
Verificación CSRF, repoblado de formulario si falla la validación, mensajes flash — todo patrones reales que reutilizarás para tus propios recursos, no escondidos detrás de una clase de form-request que tienes que configurar.
Qué incluye, y qué es opcional
El núcleo es: rutas, modelos, vistas, manejo de sesión, helpers CSRF, y un pequeño sistema de i18n (t('nav.home') + diccionarios JSON, con un selector de idioma opcional por prefijo de URL — /en/todo, /es/todo).
Más allá de eso, dos extensiones vienen en el repo pero permanecen completamente inertes hasta que las conectas:
-
tanuki_login— autenticación basada en sesión: login, registro, recuperación de contraseña por email, edición de perfil. Cero rutas registradas por defecto. -
tanuki_admin— un panel admin inspirado en Django. Registra un modelo en un array, obtén una interfaz CRUD completa para él:
// admin/admin.php
return [
'todo' => [
'model' => TodoModel::class,
'label' => 'Tareas',
'list_fields' => ['id', 'title', 'completed', 'created_at'],
'form_fields' => [
'title' => ['type' => 'text', 'label' => 'Título'],
'completed' => ['type' => 'checkbox', 'label' => 'Completada'],
],
],
];
¿No quieres ninguna de las dos? Borra la carpeta y las dos líneas de routes.php que la referencian. Nada más en el proyecto depende de ellas — ese aislamiento fue una restricción de diseño deliberada desde el primer día.
Seguridad, tomada en serio sin ser invisible
- Todos los métodos de
Modelusan prepared statements, y los nombres de columna/tabla se validan contra un patrón estricto de identificador (esto es un fix real — encontré y cerré un vector de inyección vía nombre de columna durante el desarrollo, y hay un test de regresión para ello). - La protección CSRF existe como helpers explícitos (
csrf_field(),csrf_verify()) que añades por formulario — no una política general aplicada en silencio a cada ruta, que rompería endpoints tipo webhook que no usan sesiones. - Las contraseñas usan
password_hash()/password_verify(), los tokens de recuperación son hashes SHA-256 de un solo uso con expiración, las sesiones se regeneran en login/logout para prevenir fijación.
Nada de esto es exótico — es lo estándar hecho correctamente y hecho visible, no enterrado en un middleware de seguridad que nunca lees.
Traducciones que no se interponen en tu camino
El i18n es una de esas cosas que los frameworks o se saltan por completo, o convierten en todo un subsistema que tienes que aprender. Tanuki hace la versión mínima útil: un diccionario JSON por idioma, un helper.
// lang/es.json
{ "nav": { "home": "Inicio" }, "todo": { "flash_created": "¡Tarea creada correctamente!" } }
t('nav.home') // → "Inicio"
t('todo.flash_created') // → "¡Tarea creada correctamente!"
t('todo.count_summary', ['total' => 5]) // → sustituye ":total" en el string
Si una clave falta en el idioma activo, cae al inglés; si falta en todos lados, te devuelve la clave tal cual en vez de romper la página — una traducción faltante se ve, no falla en silencio.
También hay un selector de idioma por URL (/en/todo, /es/todo) conectado desde el principio, pero permanece totalmente inactivo a menos que definas ACCEPTED_LANGUAGES en el .env:
ACCEPTED_LANGUAGES=en,es
Visita /es/todo una vez, y la elección se queda en sesión — el resto de enlaces del sitio funcionan sin prefijo. No definas esa variable, y el selector nunca se renderiza: el framework asume que tu proyecto es de un solo idioma por defecto, y solo te pide pensar en idiomas cuando realmente necesitas más de uno.
Cómo empezar
composer create-project tu-usuario/tanuki-base mi-proyecto
cd mi-proyecto
cp .env-example .env
nano .env # ajusta DB_NAME, DB_USER, DB_PASS, APP_URL
php -S localhost:8050 -t public public/index.php
Visita /todo para el ejemplo funcional, o empieza desde cero con tu propia ruta → controlador → modelo → vista.
Soporte a largo plazo, por diseño
Tanuki no está pensado para perseguir tendencias. El objetivo es un framework lo suficientemente estable como para que un proyecto construido hoy sobre él siga funcionando, sin cambios, dentro de cinco años — parches menores, sin reescrituras que rompan nada. Si eso te resulta atractivo, o si simplemente quieres curiosear un código PHP pequeño y legible, el repo y la wiki con documentación completa están abajo.
Packagist: https://packagist.org/packages/forja-de-onix/tanuki-framework
Repo: https://github.com/Forja-de-Onix/tanuki-framework
Wiki (documentación completa): https://github.com/Forja-de-Onix/tanuki-framework/wiki/Inicio
Feedback, issues y PRs bienvenidos — especialmente de otros fans del PHP vanilla que quieran encontrarle agujeros al diseño.
This article was originally published by DEV Community and written by Technomantus Corvi.
Read original article on DEV Community