dmvdigital / dmvapp
Micro framework PHP para shared hosting (FTP, sin CLI).
Requires
- php: >=8.3
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Micro framework PHP para prototipar rápido en shared hosting (cPanel/LAMP) con deploy por FTP, sin CLI ni SSH. Slim 4 (rutas) · Eloquent (ORM + migraciones) · phpsfc (vistas) · PHP-DI · respect/validation.
Estructura
public/ → docroot del hosting (public_html). Solo esto es accesible por URL.
index.php carga public_config.php y arranca la app
public_config.php APP_PUBLIC_URL, APP_PUBLIC_DIR, APP_DIR (uno por entorno, no se sobreescribe)
.htaccess rewrite a index.php, bloquea dotfiles
uploads/ archivos subidos (.htaccess: sin ejecución de scripts)
assets/ generado por phpsfc (asset(), bundles css/js)
app/ → APP_DIR, FUERA del docroot (ej: /home/usuario/app)
.env claves y flags (ver .env.example)
core/ framework. Se reemplaza entero al actualizar. Incluye core/vendor/
src/ tu app (namespace App\, PSR-4)
routes.php rutas web (HTML)
routes_api.php rutas API, montadas en /api (JSON {error, output})
config.php timezone, time_limit, memory_limit, headers de seguridad
cron.php tareas periódicas (expresiones cron)
error_view.php OPCIONAL: página de error propia (PHP plano)
container.php definiciones PHP-DI propias
Controllers/ Models/ clases App\...
views/ componentes phpsfc (pueden sobreescribir core/views)
migrations/ seeds/
lang/ overrides de mensajes de validación
composer.json, vendor/ OPCIONAL: dependencias propias del proyecto
.cache/ vistas, sesiones, rate limit, estado del cron (generado)
.MAINTENANCE OPCIONAL: si existe, el sitio responde 503 (ver Mantención)
logs/ app-AAAA-MM-DD.log (generado)
database.sqlite si usas SQLite (ruta configurable con DB_DATABASE)
Local
Requiere PHP 8.3+ (probado en 8.3 y 8.5) y Composer.
composer create-project dmvdigital/dmvapp miproyecto
O desde un clon: composer install en la raíz. Ambos instalan app/core/vendor y crean app/.env desde .env.example con MIGRATE_TOKEN y ADMIN_PASSWORD random (si .env ya existe no se toca). El composer.json de la raíz solo lanza ese setup: su vendor/ queda vacío y no se sube.
php -S localhost:8000 -t public
Luego http://localhost:8000/_migrate → token de MIGRATE_TOKEN → up y seed (crea el admin de ADMIN_EMAIL/ADMIN_PASSWORD).
Chequeo rápido del core (sin servidor):
php app/core/tests/selfcheck.php
Para que create-project funcione por nombre, el repo debe estar público y registrado en Packagist con un tag (v1.0.0). Sin Packagist: agrega --repository='{"type":"vcs","url":"https://github.com/usuario/dmvapp"}'.
Deploy por FTP
Antes de subir, en local: cd app/core && composer install --no-dev -o (y lo mismo en app/src si tiene composer.json).
Primer deploy:
- Sube
app/a una carpeta fuera del docroot (ej./home/usuario/app), sin.cache/,logs/nidatabase.sqlitelocal. - Sube el contenido de
public/apublic_html/(o la subcarpeta del proyecto). - En el servidor, edita
public_html/public_config.php:APP_PUBLIC_URL(con https y subcarpeta si aplica, sin slash final),APP_DIR(ruta absoluta a la carpeta app). - Crea
app/.envdesde.env.example:APP_ENV=production,APP_DEBUG=false, datos MySQL de cPanel,MIGRATE_TOKENlargo y random (MIGRATE_ACTIONS=status,uprecomendado). - Abre
https://tusitio.cl/_migrate, ejecutaup(yseedsi lo dejaste habilitado). - Opcional: vacía
MIGRATE_TOKENcuando no lo uses; la ruta deja de existir.
Permisos de escritura (PHP): app/.cache/, app/logs/, public_html/uploads/, public_html/assets/ y la carpeta de la sqlite si la usas.
Actualizar:
- Tu código: sube los archivos cambiados de
app/src/ypublic/. Si cambiaste vistas y algo raro pasa, borraapp/.cache/views/. - Framework: borra
app/core/en el servidor y sube elcore/nuevo completo (trae suvendor/).src/no se toca. - Dependencias del proyecto: sube
app/src/vendor/.
Clave olvidada o cuenta bloqueada (no hay mail de recuperación): pon un RESET_TOKEN largo y random en app/.env por FTP, abre https://tusitio.cl/_reset-password, ingresa token + email. Genera una clave nueva, desbloquea la cuenta y la muestra una sola vez en pantalla (no queda en .env ni en logs). Después vacía RESET_TOKEN.
Rutas internas (/_migrate, /_reset-password, /_logs, /_cache, /_cron): token por POST, nunca en la URL. 5 tokens inválidos desde una IP bloquean esa IP 15 min en todas (borra app/.cache/ratelimit/ por FTP si te bloqueaste).
/_logs: últimas 500 líneas de cadalogs/app-*.log, filtro por nivel. Solo lectura./_cache: vacíaapp/.cachepor tipo (views,sessions= cierra la sesión de todos,ratelimit,cron) o completo./_reset-password: además de la clave nueva, cierra las sesiones de esa cuenta (y opcionalmente las de todos).
Mantención
Sube un archivo app/.MAINTENANCE: todo responde 503. Vacío = mensaje por defecto; con contenido = se sirve tal cual (tu HTML). En /api responde {"error": "Sitio en mantención...", "output": null}. Las rutas /_* siguen funcionando (para migrar durante el deploy). Bórralo para volver.
Cron y cola
En cPanel → Cron Jobs, cada minuto (o cada 5/15; las tareas se ponen al día):
php /home/usuario/public_html/index.php cron
Sin cron con PHP: un webcron que haga POST /_cron con token=CRON_TOKEN. Cada corrida ejecuta las tareas vencidas de src/cron.php y después procesa la cola (dispatch()), hasta CRON_SECONDS. Corridas superpuestas se saltan.
Si el hosting obliga a dejar app/ dentro del docroot, app/.htaccess bloquea el acceso, pero fuera es lo correcto.
Varias apps, un core (pseudo multi tenant)
public_config.php acepta dos overrides opcionales:
define('APP_CORE_DIR', '/home/usuario/dmvapp-core'); // default: APP_DIR/core define('APP_SRC_DIR', '/home/usuario/tienda-src'); // default: APP_DIR/src
Lo propio de cada app sigue en su APP_DIR: .env, .cache/ (vistas y sesiones), logs/, sqlite. Cada docroot tiene su uploads/ y assets/.
/home/usuario/
dmvapp-core/ core compartido (con su vendor/)
tienda-src/ src compartido por tienda-a y tienda-b
tienda-a/ tienda-b/ APP_DIR de cada una: .env, .cache, logs, sqlite
blog/ APP_DIR con su propio src/ (sin APP_SRC_DIR)
public_html/tienda-a/ public_html/tienda-b/ public_html/blog/
- Actualizar el core compartido actualiza todas las apps a la vez: prueba cada
srccontra el core nuevo antes de subirlo. Para migrar de a una, sube el core nuevo a otra carpeta (dmvapp-core-2) y cambiaAPP_CORE_DIRapp por app. - Con
srccompartido, la diferencia entre apps va en.env(DB, mail, flags). Las migraciones se corren en el/_migratede cada app. - Todas las apps corren con el mismo usuario del hosting: es separación de datos y config, no de seguridad.
Configuración (.env)
| Variable | Default | |
|---|---|---|
APP_ENV / APP_DEBUG |
– / false | debug muestra errores en pantalla: traza en web, detail en JSON (solo 5xx) |
APP_LOCALE |
es | mensajes de respect/validation (core/lang/ + overrides de src/lang/validation.{locale}.php); sin archivo = inglés |
SESSION_ENABLED |
true | sesión nativa, guardada en .cache/sessions |
CSRF_ENABLED |
true | requiere sesión; csrf_field() en cada form POST |
AUTH_ENABLED |
true | requiere sesión; agrega migración core_users |
LOG_ENABLED |
true | logs/app-AAAA-MM-DD.log |
DB_CONNECTION |
– | sqlite o mysql; vacío = sin DB |
DB_DATABASE |
database.sqlite | sqlite: ruta relativa a APP_DIR; mysql: nombre de la DB |
MIGRATE_TABLE |
core_migrations | tabla de control |
MIGRATE_TOKEN |
– | vacío = /_migrate no existe |
MIGRATE_ACTIONS |
status,up | * o lista de status,up,rollback,seed |
RESET_TOKEN |
– | vacío = /_reset-password no existe |
RESET_EMAILS |
ADMIN_EMAIL | cuentas que se pueden reiniciar: lista o * |
LOGS_TOKEN / CACHE_TOKEN / CRON_TOKEN |
– | vacío = /_logs, /_cache, /_cron no existen |
CRON_SECONDS |
50 | tiempo máximo por corrida del cron |
HEADERS_ENABLED |
true | headers de seguridad (src/config.php) |
API_CSRF |
true | false = /api sin CSRF (APIs para terceros) |
QUEUE_ENABLED / QUEUE_TRIES |
true / 3 | cola en tabla core_jobs (requiere DB; agrega su migración) |
MAIL_DRIVER |
native | native (mail()), http (API, formato Resend), off |
MAIL_FROM, MAIL_FROM_NAME, MAIL_API_URL, MAIL_API_KEY |
Valores con # van entre comillas.
Uso
// src/routes.php $app->get('/posts/{id}', function ($req, $res, $args) { return view($res, 'pages/post', ['post' => App\Models\Post::findOrFail($args['id'])]); }); $app->get('/admin', [App\Controllers\AdminController::class, 'index'])->add(Core\Http\AuthMiddleware::class);
Helpers (core/helpers.php): env(), config(), app(), view(), redirect(), url(), app_path(), public_path(), e(), csrf_field(), csrf_exempt(), flash(), old(), errors(), validate(), back_with_errors(), auth(), rate_limit(), api_fail(), dispatch().
Una ruta web puede devolver un Response o un string (HTML). /api y /_ están reservados: van en routes_api.php y en el core.
API
// src/routes_api.php → /api/posts/{id} return function (Slim\Routing\RouteCollectorProxy $api) { $api->get('/posts/{id}', function ($req, $res, array $args) { $post = App\Models\Post::find($args['id']) ?? api_fail('No existe el post', 404); return $post; // cualquier valor }); };
Siempre responde {"error": "", "output": <valor>}; con error {"error": "mensaje", "output": null} (con api_fail($errores, 422) el error puede ser un array campo => mensaje). 404, 500, etc. en /api también salen así. Devolver un Response lo deja pasar tal cual (descargas, etc). CSRF aplica a /api (manda X-CSRF-Token desde el JS del sitio); API_CSRF=false lo quita para todo /api, csrf_exempt($api->post(...)) para una ruta.
Rate limit por ruta: ->add(new Core\Http\RateLimitMiddleware(5, 60)) (5 por minuto por IP, 429 con Retry-After). Manual: if (!rate_limit("contacto:$email", 3, 3600)) ....
Cola
// src/Jobs/EnviarBienvenida.php final class EnviarBienvenida { public function handle(array $payload): void { Core\Mail::send($payload['email'], ...); } } dispatch(App\Jobs\EnviarBienvenida::class, ['email' => $email]); // próximo cron dispatch(App\Jobs\EnviarBienvenida::class, ['email' => $email], 3600); // en 1 hora
Si falla se reintenta (1, 2... min de espera) hasta QUEUE_TRIES; después queda en core_jobs con failed_at y error.
Errores y config
Páginas de error: crea src/error_view.php (PHP plano) con $error_number, $error_title, $error_message y $error_detail (traza, solo con APP_DEBUG=true y 5xx). Sin él se usa core/error_view.php.
src/config.php: timezone, time_limit, memory_limit y headers (se suman a los del core: nosniff, X-Frame-Options, Referrer-Policy, Permissions-Policy, CSP mínima, HSTS en https; null quita uno). Una ruta que setea un header lo pisa.
use Respect\Validation\Validator as v; $errors = validate($data, ['email' => [v::email(), 'Email inválido']]); if ($errors) return back_with_errors('/contacto', $errors, $data);
Sin mensaje propio, el error sale en español desde core/lang/validation.es.php (email debe ser un email válido). Usa ->setName('Email') en la regla para cambiar el nombre mostrado. Para cambiar textos globalmente, agrega la plantilla en inglés a src/lang/validation.es.php (pisa la de core; un src/lang/validation.{locale}.php también sirve para idiomas que core no trae).
Archivos públicos (Core\PublicFiles): upload($uploadedFile, 'uploads/fotos', ['jpg','png'], $maxBytes), put('data/x.json', $json), copy('src/assets/logo.svg', 'img/logo.svg'), url(), delete(). Validan extensión + MIME, nunca escriben .php/.htaccess y no salen de la carpeta pública. Para assets de componentes, phpsfc ya trae $this->asset().
Mail: Core\Mail::send('a@b.cl', 'Asunto', '<p>html</p>', ['reply_to' => ..., 'attachments' => [...]]). Para otra API HTTP, define Core\Mail::$payload = fn(array $m) => [...].
Editor visual de phpsfc: no viene montado. Ver comentario al final de src/routes.php.