Arquitectura interna y desarrollo de módulos propios
Arquitectura interna y desarrollo de módulos propios
En los tres capítulos anteriores escribiste adaptadores: un Payment_Adapter en el capítulo 10, un Server_Manager en el capítulo 11 y un Registrar_Adapter en el capítulo 12. Los tres encajan en un hueco que el core ya tenía preparado: una clase suelta en src/library/ y el resto lo hace él. Nunca tuviste que saber cómo arranca la aplicación.
Un módulo es otra cosa. Registra rutas, aporta entradas de menú, expone endpoints de API, declara permisos de staff, crea sus tablas, engancha hooks y renderiza plantillas. Para escribirlo sin ir a ciegas hay que saber qué pasa entre que llega el GET y sale el HTML, quién instancia tus clases y con qué, y por qué hay dos ORM sobre la misma conexión. El problema añadido es que casi toda la documentación de terceros describe la estructura anterior a 0.8, y el propio example-module oficial también: en su último commit 56f6ff4, del 8 de marzo de 2026, sigue usando html_admin/, html_client/ y html_email/, directorios que en 0.8.5 no carga nadie. Copiar ese layout produce un módulo que se activa, sale en el menú y devuelve 404 en todas sus páginas.
Al terminar tendrás el mapa de src/ verificado contra FOSSBilling 0.8.5 y un módulo propio activado, con rutas admin y cliente, API en los tres roles, tabla propia, permisos y página de ajustes.
No es Symfony ni Laravel: un framework propio con componentes prestados
FOSSBilling no es una aplicación Symfony. Es el framework que heredó de BoxBilling, al que se le han enchufado componentes de Symfony uno a uno: no hay bundles, ni services.yaml, ni autowiring, ni AbstractController. El require de composer.json en 0.8.5 fija php >=8.3, pimple/pimple ^3.6, doctrine/orm ^3.6, gabordemooij/redbean ^5.7.5, twig/twig ^3.22.2, monolog/monolog ^3.9, dompdf/dompdf ^3.1.4, stripe/stripe-php ^21.0.0 y una veintena de componentes symfony/* en ^7.4, entre ellos symfony/console para el CLI y symfony/rate-limiter. Fíjate en los dos que no están: symfony/http-kernel y symfony/routing.
| Capa | Qué usa 0.8.5 | Qué NO usa |
|---|---|---|
| Contenedor DI | Pimple 3 en un fichero, src/di.php, unas 700 líneas | Symfony DI, PHP-DI |
| Request y Response | symfony/http-foundation | symfony/http-kernel |
| Enrutado | FOSSBilling\Http\RouteMatcher, regex propias | symfony/routing |
| ORM | Doctrine ORM 3.6 y RedBeanPHP 5.7 a la vez | — |
| Migraciones | structure.sql más UpdatePatcher | doctrine/migrations |
Advertencia: la guía oficial 0.7 → 0.8 afirma que “the custom HTTP and routing layer has been replaced by Symfony’s HttpKernel, HttpFoundation, and Routing components”. El código lo contradice a medias: HttpFoundation sí; HttpKernel y Routing no aparecen en composer.json y Box_App::processRequest() sigue llamando a RouteMatcher. Compruébalo con grep -n 'symfony/routing\|symfony/http-kernel' composer.json, que no devuelve nada.
El doble ORM tampoco es un accidente silencioso. Está escrito en el AGENTS.md de la raíz, la descripción de arquitectura que mantienen los propios desarrolladores, y es la instrucción que debes seguir en tu módulo:
“FOSSBilling is in the process of migrating modules and core parts from RedBeanPHP to Doctrine one by one. (…) When writing new pieces of code, avoid RedBeanPHP.”
El árbol de src/ y el papel real de cada directorio
Todo el código vive bajo src/, que además es el docroot del servidor web:
src/
data/ runtime: cache, log, uploads. Escribible por el servidor web
install/ asistente + install/sql/structure.sql y content.sql
library/ clases del core: Box (heredadas, Box_App y Box_Database), FOSSBilling
(modernas), Model (beans RedBean), Payment, Registrar, Server
locale/ traducciones gettext, submodulo FOSSBilling/locale
modules/ los 40 modulos
public/ assets compartidos: assets, branding, gateways
themes/ admin_default y huraga
vendor/ dependencias de composer
console.php entrypoint CLI con Symfony Console
cron.php entrypoint de tareas programadas
di.php contenedor Pimple
index.php front controller HTTP
ipn.php receptor de notificaciones de pasarelas de pago
load.php bootstrap: preInit, init, postInit
Uno. library/ está partido en dos culturas: Box/ son clases heredadas con nombres Box_Algo y sin namespace, FOSSBilling/ es el código moderno con namespace. Las encontrarás mezcladas dentro de la misma función y eso es normal. Dos. composer.json fija "vendor-dir": "src/vendor". Los binarios de desarrollo no están en ./vendor/bin/ sino en ./src/vendor/bin/{phpstan,pest,php-cs-fixer}. Fuera de src/ viven frontend/ (fuente JS y CSS con esbuild), tests/, .github/workflows/, .ddev/ y los ficheros de calidad phpstan.neon, rector.php, .php-cs-fixer.dist.php y phpunit.xml.dist.
Autoload: psr-0 para adaptadores, psr-4 para Box\Mod\ y classmap para library
El bloque autoload explica por qué los adaptadores se llaman Payment_Adapter_Loquesea y tu módulo se llamará Box\Mod\Loquesea:
"autoload": {
"psr-0": { "Payment_": "src/library/", "Registrar_": "src/library/", "Server_": "src/library/" },
"psr-4": { "Box\\Mod\\": "src/modules/" },
"classmap": ["src/library/"]
}
psr-0 hace que Server_Manager_Hestia se busque en src/library/Server/Manager/Hestia.php; psr-4 hace que Box\Mod\News\Service se busque en src/modules/News/Service.php; y el classmap es un mapa estático de todas las clases sueltas de src/library/.
Gotcha: ese classmap se genera en tiempo de instalación. Una clase nueva en src/library/ que no caiga bajo uno de los tres prefijos psr-0 no se carga hasta que ejecutes composer dump-autoload. Los módulos no tienen ese problema: PSR-4 resuelve por convención de rutas en cada petición.
Bootstrap en load.php: preInit, init y los códigos de excepción 1, 2, 3 y 5
src/load.php es el bootstrap común de index.php, console.php y cron.php. Al final del fichero ejecuta esta secuencia, en este orden exacto:
preInit(); // rutas, autoloader de composer, carga manual de libs criticas
init(); // config, DI, sesiones, Sentry
checkInstaller();
checkSSL();
checkWebServer();
postInit(); // error handlers e ini_set de logging
preInit() define PATH_ROOT (que es src/), PATH_VENDOR, PATH_LIBRARY, PATH_THEMES, PATH_MODS, PATH_LANGS, PATH_UPLOADS y PATH_CONFIG antes de que exista el autoloader, y hace require manual de las clases críticas: ErrorPage.php, SentryHelper.php, Environment.php, las siete de Http/ (ApiResponseFactory, ExceptionResponseFactory, RequestFactory, ResponseFactory, RouteDefinition, RouteMatch, RouteMatcher), Config.php y Tools.php.
init() registra los manejadores de error, crea el Request de HttpFoundation con RequestFactory::createFromGlobals(), fija la zona horaria desde i18n.timezone, define ADMIN_PREFIX, DEBUG, PATH_DATA, PATH_CACHE, PATH_LOG, INSTANCE_ID, SYSTEM_URL y BIND_TO, y carga el contenedor con $di = require Path::join(PATH_ROOT, 'di.php');. A partir de esa línea todo lo demás es $di['algo'].
Los cuatro códigos de excepción de arranque son la herramienta de diagnóstico más rápida cuando la instalación no levanta:
| Código | Mensaje | Causa y arreglo |
|---|---|---|
1 | The composer packages are missing. | falta src/vendor → composer install |
2 | For security reasons, you have to delete the install directory... | install/ presente en producción. Con APP_ENV=prod explícito y sin DEBUG, checkInstaller() lo borra solo |
3 y 5 | The FOSSBilling configuration file is empty or invalid. y Missing .htaccess file | config.php roto (php -l src/config.php); Apache o Litespeed sin .htaccess (restaurar src/.htaccess) |
Si no existe config.php pero sí install/install.php, init() no lanza nada: redirige con un 307 al instalador. Las claves de ese fichero están en el capítulo 3.
Ciclo de vida de una petición: de index.php a la Response de Symfony
Recorrido completo de un GET /admin/news/post/3:
sequenceDiagram
participant N as Navegador
participant I as index.php
participant L as load.php y di.php
participant A as Box_AppAdmin
participant M as FOSSBilling Module
participant C as Controller Admin
N->>I: rewrite a index.php con _url
I->>L: require load.php y luego di.php
L-->>I: constantes mas di mas request
I->>I: normalizeRoutePath y session getId
alt la ruta empieza por ADMIN_PREFIX
I->>A: new Box_AppAdmin y define ADMIN_AREA true
else
I->>A: new Box_AppClient y define ADMIN_AREA false
end
I->>A: run y luego registerModule con el primer segmento
A->>M: di mod news
M->>C: getAdminController y luego register
A->>A: checkPermission y RouteMatcher match
A->>C: invoca el metodo por Reflection
C->>C: render con TwigFactory
C-->>A: string o Response
A-->>I: Symfony Response y cookie_queue applyToResponse
I->>N: emitResponse
Uno. index.php decide el área con strncasecmp($url, ADMIN_PREFIX, strlen(ADMIN_PREFIX)) === 0 y define la constante ADMIN_AREA, que tu módulo puede leer. Dos. Box_AppClient::init() define API_MODE cuando el primer segmento es api y además fuerza ini_set('display_errors','0') para que un warning de PHP no destroce el JSON; por eso un error en un endpoint se comporta distinto que en una página normal aunque tengas debug activo.
Tres. El parámetro de query _url trae la ruta real cuando hay rewrite. RequestFactory::normalizeRoutePath() lo lee primero y solo cae a getPathInfo() si no existe; esa misma función reescribe internamente /page/... a /custompages/..., que es por lo que las páginas CMS no viven donde parece. Cuatro. La respuesta pasa por $di['cookie_queue']->applyToResponse($response) y luego por emitResponse(), que hace $response->prepare($request)->send() y un exit.
El router propio: RouteMatcher, placeholders y la regla del primer segmento
Tres clases en src/library/FOSSBilling/Http/ implementan el enrutado: RouteDefinition (final readonly, con httpMethod, path, methodName, conditions y controllerClass), RouteMatch (matched y params) y RouteMatcher, que convierte el patrón en regex. La API que expone Box_App es $app->get|post|put|delete(string $url, string $methodName, ?array $conditions = [], ?string $class = null): void. Los placeholders se escriben :nombre y su regex por defecto, literal de RouteMatcher::buildRouteRegex(), es $regex .= '(' . ($conditions[$key] ?? '[a-zA-Z0-9_\-]+') . ')';. Se sobrescribe con $conditions:
$app->get('/news/post/:id', 'get_post', ['id' => '[0-9]+'], static::class);
$app->get('/news/:slug', 'get_news_item', ['slug' => '[a-z0-9-]+'], static::class);
Cuatro reglas que producen 404 inexplicables:
Uno. El nombre del placeholder se extrae con preg_match_all('@:([a-zA-Z_\-]+)@', ...), que no admite dígitos. Un :id2 se parsea a medias y la ruta nunca casa: usa :id y :otherId. Dos. HEAD se trata como GET.
Tres. Si pasas $class, la ruta es shared: el controlador se instancia con invokeSharedController(), recibe el DI si implementa InjectionAwareInterface y su método recibe $app como primer argumento. Sin $class, el método debe existir en la propia Box_App* y no recibe $app. Por eso todos los módulos del core registran static::class y sus métodos empiezan por public function get_index(\Box_App $app): string. Olvidarlo es la causa más frecuente de 404 en un módulo nuevo.
Cuatro, la más restrictiva: Box_App::registerModule() toma explode('/', $requestUri)[0], el primer segmento de la URL, y solo carga las rutas de ese módulo. Un módulo llamado Example únicamente puede servir /example/.... No hay forma soportada de registrar /foo desde Example; si necesitas una URL bonita en la raíz, la respuesta del core es el módulo Redirect. En el área cliente, Box_AppClient::init() registra además siempre GET '' y GET '/' hacia get_index, más un comodín GET|POST '/:page' con condición ['page' => '[a-z0-9-/.//]+'] que resuelve a get_custom_page.
di.php por dentro: recorrido por los servicios del contenedor Pimple
src/di.php es un Pimple\Container y es todo el sistema de inyección del proyecto: no hay compilación, ni caché de contenedor, ni más ficheros de servicios.
graph TD
DI["Pimple Container en src/di.php"]
subgraph DATOS["Datos"]
D1["db - RedBean"]
D2["dbal y em - Doctrine mas pager"]
end
subgraph API["API"]
P1["api_guest, api_client, api_admin"]
P2["api_system solo interno y api_dispatcher"]
end
subgraph SEG["Seguridad"]
S1["auth, session, rate_limiter, crypt, password"]
end
subgraph PRES["Presentacion"]
R1["twig_factory, theme, url"]
end
subgraph INFRA["Infraestructura"]
I1["logger, cache, http_client, events_manager"]
I2["mod, mod_service, mod_config"]
end
DI --> DATOS
DI --> API
DI --> SEG
DI --> PRES
DI --> INFRA
Los que usarás a diario:
| Clave | Devuelve | Para qué |
|---|---|---|
logger | Box_Log | a la tabla de actividad y a Monolog: ->setChannel('mimodulo')->info(...) |
db | Box_Database | fachada de RedBeanPHP, código legado |
dbal / em | DBAL\Connection / ORM\EntityManager | SQL crudo moderno y el ORM que hay que usar en código nuevo |
pager / url | Pagination / Box_Url | paginación de listados; link() y adminLink() |
mod, mod_service, mod_config | factorías | el Module, su Service.php y su config cifrada |
events_manager | Box_EventManager | ->fire(['event' => ..., 'subject' => ..., 'params' => ...]) |
session, cache, http_client, rate_limiter, crypt, auth | varios | sesión con handler PDO, FilesystemAdapter con TTL 24 h en PATH_CACHE, cliente HTTP con User-Agent FOSSBilling/<version>, limitador, cifrado con info.salt y isAdminLoggedIn() |
api_guest, api_client, api_admin | FOSSBilling\Api\Proxy | llamar a la API desde PHP |
api_system | FOSSBilling\Api\Proxy | solo interno: cron e IPN, identidad de cron admin |
tools, validator, twig_factory | varios | slug(), sortByOneKey(), checkRequiredParamsForArray() y los entornos Twig |
api_system no es enrutable por HTTP: los roles que acepta el router de la API son guest|client|admin, y una llamada a /api/system/... cae en el comodín y devuelve Unknown API call :call con código 879. Lo verás en detalle en el capítulo 14.
InjectionAwareInterface y los servicios con efecto secundario
No hay autowiring. El mecanismo completo es una interfaz más una línea dentro de FOSSBilling\Module:
interface InjectionAwareInterface
{
public function setDi(\Pimple\Container $di): void;
public function getDi(): ?\Pimple\Container;
}
// en FOSSBilling\Module, al instanciar Services y Controllers:
if (method_exists($service, 'setDi')) { $service->setDi($this->di); }
Tu clase implementa la interfaz y recibe $di justo después de construirse. El boilerplate es siempre protected ?\Pimple\Container $di = null; con los dos métodos.
Gotcha de Pimple que descoloca la primera vez: $di['is_admin_logged'] y $di['is_client_logged'] no son booleanos. Son servicios con efecto secundario: leerlos lanza AuthenticationRequiredException si no hay sesión válida. Se invocan escribiendo la línea suelta, sin asignarla:
public function get_index(\Box_App $app): string
{
$this->di['is_admin_logged']; // lanza excepcion y manda al login si no hay admin
return $app->render('mod_cookieconsent_index');
}
Un if ($this->di['is_admin_logged']) funciona igual, pero engaña: la excepción saltó antes de evaluar el if. El core usa siempre la línea suelta y conviene imitarlo.
RedBeanPHP en modo freeze: qué puedes y qué no puedes hacer con $di[‘db’]
$di['db'] es un Box_Database, la fachada sobre RedBeanPHP 5. Su API pública, verificada en src/library/Box/Database.php:
dispense($modelName); store($modelOrBean); transaction(callable $callback);
getAll($sql, $values = []); getCell($sql, $values = []); getRow($sql, $values = []); getAssoc($sql, $values = []);
findOne(string $modelName, ?string $sql = null, $values = []); find(string $modelName, ?string $sql = null, $values = []);
findAll(string $table, ?string $sql = null, array $bindings = []); load($modelName, $id);
exec($sql, $values = []); trash($modelOrBean); toArray($modelOrBean);
getExistingModelById($modelName, $id, $message = 'Model :name not found in the database');
Los modelos son Model_Client, Model_Admin, Model_Invoice y compañía, en src/library/Model/, todos extendiendo RedBeanPHP\SimpleModel. Lo que no puedes hacer: RedBean corre en modo freeze por defecto (db.freeze, valor true), así que no crea tablas ni columnas al vuelo. Asignar $bean->campo_que_no_existe y hacer store() no te da una columna nueva, te da un error. Toda columna la creas tú con SQL en Service::install(). Hay un comentario en src/di.php que explica un fallo real y por qué no debes tocar esa línea:
// SECURITY: bind string literals as PARAM_STR, not PARAM_INT. Without
// this, ?hash=107 in /api/guest/invoice/get resolves to the invoice
// whose hash starts with '107' because MySQL coerces VARCHAR against
// int to a leading-digits match. Do not remove; see RedBeanBindingTest.
Facade::getDatabaseAdapter()->getDatabase()->setUseStringOnlyBinding(true);
Sin setUseStringOnlyBinding(true), una petición con ?hash=107 a un endpoint público de facturas devolvía la factura cuyo hash empieza por 107, porque MySQL compara VARCHAR contra entero por dígitos iniciales.
Doctrine ORM 3: entidades autodescubiertas, atributos y naming strategy
El lado moderno vive en src/library/FOSSBilling/Doctrine/EntityManagerFactory.php y descubre las entidades solo con (new Finder())->directories()->in(PATH_MODS . '/*/Entity')->depth('== 0'). Cualquier carpeta src/modules/<Modulo>/Entity/ se registra sin declarar nada. El mapeo es por atributos PHP (ORMSetup::createAttributeMetadataConfig) y la estrategia de nombres es new UnderscoreNamingStrategy(CASE_LOWER), elegida según el comentario del propio fichero por “consistency with already existing RedBean tables”. Consecuencia directa: una propiedad $adminId mapea a la columna admin_id sin escribir name:. Como los dos ORM comparten tablas, esa coherencia es lo que permite que una entidad Doctrine y un bean de RedBean vean la misma fila.
flowchart LR
LEG["Codigo legado en modulos y library"] --> RB["Box_Database sobre RedBeanPHP 5 en modo freeze"]
NEW["Codigo nuevo en Api y Repository"] --> EM["Doctrine EntityManager en di em"]
DISC["Finder sobre src/modules con Entity y depth 0"] --> META["Metadatos por atributos PHP"]
META --> NAMING["UnderscoreNamingStrategy CASE_LOWER"]
NAMING --> EM
META --> MCACHE["FilesystemAdapter doctrine en PATH_CACHE con seed de mtime y size"]
MCACHE --> EM
RB --> DBM[("MySQL o MariaDB - las mismas tablas")]
EM --> DBM
La caché de metadatos es un FilesystemAdapter('doctrine', 0, PATH_CACHE) con un seed calculado del mtime y tamaño de todos los ficheros de entidad: al editar una entidad se invalida sola. Si aun así no aparece, rm -rf src/data/cache/*. En PHP 8.4 o superior activa enableNativeLazyObjects(true); en 8.3 genera proxies en PATH_CACHE/doctrine/proxies. Una entidad real del core, src/modules/News/Entity/Post.php, recortada:
namespace Box\Mod\News\Entity;
use Doctrine\ORM\Mapping as ORM;
use FOSSBilling\Doctrine\TimestampTrait;
use FOSSBilling\Interfaces\{ApiArrayInterface, TimestampInterface};
#[ORM\Entity(repositoryClass: \Box\Mod\News\Repository\PostRepository::class)]
#[ORM\Table(name: 'post')]
#[ORM\HasLifecycleCallbacks]
class Post implements ApiArrayInterface, TimestampInterface
{
use TimestampTrait;
public const STATUS_ACTIVE = 'active';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
private ?int $id = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
private int $admin_id; // la propiedad $adminId mapearia a esta misma columna
public function __construct(
#[ORM\Column(type: Types::STRING, length: 255)] private string $title,
#[ORM\Column(type: Types::STRING, length: 255, unique: true)] private string $slug,
) {}
}
Las interfaces disponibles en src/library/FOSSBilling/Interfaces/ son ApiArrayInterface (obliga a toApiArray(): array), TimestampInterface, SecurityCheckInterface y WidgetProviderInterface.
Paginación moderna: PaginationOptions y paginateDoctrineQuery
Todos los endpoints *_get_list devuelven la misma forma, y hay una clase que la garantiza:
final readonly class PaginationOptions
{
public const int MAX_PER_PAGE = 500;
public const int DEFAULT_PER_PAGE = 100;
public function __construct(
public int $page = 1, public int $perPage = self::DEFAULT_PER_PAGE,
public string $pageParam = 'page', public string $perPageParam = 'per_page',
) { }
public static function fromArray(array $data, string $pageParam = 'page', string $perPageParam = 'per_page'): self
}
Un page menor que 1 produce Page number (page) must be a positive integer. y un per_page por encima de 500, The number of items per page (per_page) is too large. Please specify a smaller number. Los valores no enteros se ignoran y se usa el defecto. La respuesta es siempre ['pages' => (int) ceil($total / $perPage), 'page' => $page, 'per_page' => $perPage, 'total' => $total, 'list' => [...]], con pages a 0 si no hay resultados. En $di['pager'] tienes cuatro estrategias: paginateDoctrineQuery(QueryBuilder $qb, PaginationOptions $p, mixed ...$apiArrayArgs), la recomendada en código nuevo; paginateMappedQuery(QueryBuilder $qb, PaginationOptions $p, callable $mapper) cuando hay que transformar cada fila; getPaginatedResultSet(string $sql, array $params, PaginationOptions $p), legado con SQL crudo y LIMIT; y paginateArray(array $items, PaginationOptions $p) para listas ya en memoria. AGENTS.md lo dice sin rodeos: “paginateDoctrineQuery() is the replacement for getPaginatedResultSet().”
Discrepancia verificada: la guía 0.7 → 0.8 menciona $di['pagination']. Esa clave no existe. La real es $di['pager'], clase FOSSBilling\Pagination. Existe además FOSSBilling\Paginator, que solo produce metadatos y no es lo que buscas.
Sin Doctrine Migrations: structure.sql, content.sql y UpdatePatcher
Hay Doctrine ORM pero no hay doctrine/migrations. El esquema se gestiona por tres vías.
Uno. Instalación limpia. El asistente importa src/install/sql/structure.sql, unos 50 KB, y src/install/sql/content.sql, unos 22 KB. Dos. Actualizaciones del core. Una clase monolítica, src/library/FOSSBilling/UpdatePatcher.php, con unas 2.897 líneas y métodos privados numerados de patch25() a patch99() en la rama main. El nivel aplicado se guarda en base de datos y su API pública es availablePatches(): int, latestPatchLevel(): int y applyConfigPatches(bool $force = false): void. Este último además migra el propio config.php: rellena claves nuevas con ??=, borra las obsoletas, normaliza db.port y genera info.instance_id con Uuid::v4(). El comando que lo fuerza vive en src/modules/System/Commands/RunPatcher.php.
Tres. Tu módulo. Un módulo no core crea sus tablas en Service::install() con $di['dbal'], como hace src/modules/Massmailer/Service.php:
public function install(): void
{
$sql = 'CREATE TABLE IF NOT EXISTS `mod_massmailer` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`from_email` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 AUTO_INCREMENT=1;';
$this->di['dbal']->executeStatement($sql);
$this->di['mod_service']('extension')->setConfig(['ext' => 'mod_massmailer', 'limit' => '2']);
}
El IF NOT EXISTS no es decorativo: install() puede ejecutarse varias veces a lo largo de la vida de una instalación, así que tiene que ser idempotente.
Los 40 módulos del core y los 30 que no se pueden desactivar
ls src/modules/ en 0.8.5 devuelve 40 directorios, y 30 de ellos son intocables, listados en FOSSBilling\Module::CORE_MODULES:
public const CORE_MODULES = ['api', 'activity', 'antispam', 'cart', 'client',
'cron', 'currency', 'email', 'extension', 'hook', 'index', 'invoice', 'order',
'page', 'product', 'profile', 'security', 'servicecustom', 'servicedomain',
'servicedownloadable', 'servicehosting', 'servicelicense', 'staff', 'stats',
'support', 'system', 'theme', 'orderbutton', 'formbuilder', 'widgets'];
Desactivar uno devuelve Core modules are an integral part of the FOSSBilling system and cannot be deactivated. Los 10 restantes se comportan como extensiones de terceros: branding, cookieconsent, custompages, embed, massmailer, news, notification, redirect, seo y serviceapikey. Se activan en Extensions y pasan por install() y uninstall(). Eso los convierte en las plantillas de referencia más fiables que existen, porque recorren exactamente el mismo camino que recorrerá tu módulo: News para la versión completa con Doctrine, Cookieconsent para el mínimo viable. Hay dos tipos de módulo según AGENTS.md:
Service Modules: Represent products that can be sold (e.g., hosting packages, downloadable products). These modules’ names must start with “Service”, such as “Servicehosting”. Extension Modules: Extend FOSSBilling with additional functionality
Si tu módulo va a vender algo con ciclo de vida de servicio, del capítulo 7, su nombre tiene que empezar por Service.
Convención de nombres: por qué MyNewModule no arranca nunca
La regla más barata de cumplir y la que más tiempo cuesta cuando la incumples, porque el fallo no dice lo que pasa. La carpeta es ucfirst(strtolower(id)): primera letra mayúscula, todo lo demás minúsculas. Sin CamelCase. Example, Mynewmodule y Servicehosting funcionan; MyNewModule, example y servicehosting no. El motivo son dos líneas de FOSSBilling\Module: la ruta se resuelve con Path::join(PATH_MODS, ucfirst($this->module)) y la clase con 'Box\\Mod\\' . ucfirst($this->module) . '\\Service', siendo $this->module = strtolower($mod). Como el nombre pasa primero por strtolower(), cualquier mayúscula interior se pierde: el core busca Mynewmodule, que no existe en disco. En manifest.json el id va siempre en minúsculas, y el constructor valida con preg_match('#[a-zA-Z]#', $mod), lanzando Invalid module name (:mod). si no hay ni una letra.
Gotcha caro: en sistemas de ficheros insensibles a mayúsculas —macOS por defecto, Windows— un módulo mal capitalizado funciona en tu portátil y falla en el VPS Linux.
Anatomía de un módulo 0.8.x y la trampa de html_admin/
Estructura real de 0.8.x, con src/modules/News/ como referencia canónica:
src/modules/News/
Api/ Admin.php Guest.php extienden FOSSBilling\Api\AbstractApi
Controller/ Admin.php Client.php rutas y navegacion
Entity/ Post.php entidad Doctrine autodescubierta
Repository/ PostRepository.php
templates/
admin/ mod_news_index.html.twig mod_news_post.html.twig
client/ mod_news_index.html.twig mod_news_post.html.twig
tests/ Unit/Repository/PostRepositoryTest.php
icon.svg
manifest.json
Service.php
La tabla de migración oficial de la guía 0.7 → 0.8 dice que html_admin/ pasa a templates/admin/, html_client/ a templates/client/ y html_email/ a templates/email/.
Advertencia: no es un alias ni una ruta de compatibilidad. En 0.8.5, grep -rn 'html_admin' src/ --include='*.php' devuelve cero resultados, y src/library/FOSSBilling/Twig/TwigLoader.php solo acepta directorios cuyo padre se llame literalmente templates:
if ($this->filesystem->exists($moduleTemplatePath) && basename($parentDir) === 'templates') {
$paths[] = $moduleTemplatePath;
}
Y sin embargo FOSSBilling/example-module, en su commit 56f6ff4 del 8 de marzo de 2026, sigue publicando src/html_admin/, src/html_client/ y src/html_email/. Úsalo para el esqueleto de clases y para su workflow de CI, pero no copies su layout de plantillas. Directorios opcionales que reconoce el core:
| Directorio o fichero | Para qué sirve | Ejemplo real |
|---|---|---|
templates/email/ y templates/{admin,client}/widgets/*.html.twig | plantillas de correo en entorno Twig sandbox, y widgets para slots del panel | Servicedownloadable/templates/email/, Widgets/templates/admin/widgets/ |
templates/admin/mod_<id>_settings.html.twig | página de ajustes; su existencia activa Module::hasSettingsPage() | Antispam, Seo, Redirect |
Commands/ | comandos de Symfony Console con #[AsCommand] | Cron/Commands/Run.php |
Model/ y Service<Sub>.php | modelos POPO no-Doctrine y sub-servicios vía $di['mod_service']('order', 'Sub') | Theme/Model/Theme.php, Module::getService(string $sub) |
icon.svg | icono en Extensions y en Twig como mod_<Modulo>_icon.svg | News/icon.svg |
Y un fichero que la documentación lista pero no existe: Controller/Guest.php. La página file-structure.mdoc lo incluye en su árbol, pero FOSSBilling\Module solo conoce CONTROLLER_ADMIN_SUFFIX = '\\Controller\\Admin' y CONTROLLER_CLIENT_SUFFIX = '\\Controller\\Client'. Las rutas públicas se sirven desde Controller/Client.php, que atiende también a visitantes anónimos.
manifest.json, Service.php y getModulePermissions()
manifest.json es el único fichero obligatorio. Module::getManifest() lo lee con json_decode(..., JSON_THROW_ON_ERROR) y lo fusiona sobre unos valores por defecto que incluyen id, type, name, description, homepage_url, author, author_url, license, version, icon_url, download_url, project_url, minimum_fossbilling_version y maximum_fossbilling_version. Dos detalles: id y type se sobrescriben SIEMPRE con el nombre de la carpeta en minúsculas y con 'mod', así que lo que escribas ahí es documentación para humanos; y si defines icon_url, el core lo reescribe a '/modules/' . ucfirst($module) . '/' . $icon_url. Falta el fichero y salta el código 5897 (Missing manifest file for the :mod module.); falta Service.php o el namespace no es Box\Mod\<Ucfirst> y salta el 5898. Un JSON inválido da Invalid manifest file for the :mod module. Please check file syntax and permissions.; verifícalo con php -r 'json_decode(file_get_contents("manifest.json"), true, 512, JSON_THROW_ON_ERROR); echo "OK\n";'.
Service.php es la lógica de negocio y el sitio donde el core busca cosas por convención de nombres:
| Método | Cuándo lo llama el core |
|---|---|
install(): void | al activar la extensión, solo si no es core |
uninstall(): void y update(array $manifest): void | al desinstalar y al actualizar |
getModulePermissions(): array | al construir la matriz de permisos de staff |
getWidgets(): array | si la clase implementa WidgetProviderInterface |
public static function on<Evento>(\Box_Event $event) | hooks: método público cuyo primer parámetro sea \Box_Event. getSearchQuery($filter) y toApiArray($row) son el patrón heredado de listados con SQL crudo |
Los permisos se declaran con dos claves especiales, can_always_access y manage_settings:
public function getModulePermissions(): array
{
return [
'delete_something' => ['type' => 'bool',
'display_name' => __trans('Delete something'),
'description' => __trans('Allows the staff member to delete "something"')],
'can_always_access' => true, // acceso base al modulo sin permiso explicito
'manage_settings' => [], // restringe la pagina de ajustes
];
}
Detrás está src/modules/Staff/Service.php, con estas firmas:
public function hasPermission(?\Model_Admin $member, string $module, ?string $key = null, mixed $constraint = null): bool
public function checkPermissionsAndThrowException(string $module, ?string $key = null, mixed $constraint = null, ?\Model_Admin $member = null): void
Reglas internas verificadas de hasPermission(): index, dashboard y profile están en $alwaysAllowed y devuelven siempre true; el cron admin pasa siempre; el superadministrador pasa siempre; y can_always_access => true salta la comprobación de acceso al módulo. Cuando falla, el mensaje es You need the "<modulo>.<key>" permission to perform this action con código 403. Se comprueba de tres formas: $this->di['mod_service']('Staff')->checkPermissionsAndThrowException('example', 'delete_something'), o $this->checkPermissions('news', 'manage') desde una clase Api —que pasa la identidad y por eso funciona en cron e IPN—, o hasPermission() a mano. En Twig, con {% if has_permission('example', 'delete_something') %}. La configuración del módulo se lee con $di['mod_config']('<id>'), que va a la tabla extension_meta (extension = 'mod_<id>', meta_key = 'config'), descifra con $di['crypt'] usando info.salt y hace json_decode. Se guarda con el endpoint admin extension_config_save.
Construir un módulo mínimo funcional de principio a fin
Vamos con Miextension completo: Api/{Admin,Client,Guest}.php, Controller/{Admin,Client}.php, templates/admin/{mod_miextension_index,mod_miextension_settings}.html.twig, templates/client/mod_miextension_index.html.twig, icon.svg, manifest.json y Service.php.
{
"id": "miextension", "type": "mod", "name": "Mi Extension",
"description": "Modulo de ejemplo minimo para FOSSBilling 0.8.x", "icon_url": "icon.svg",
"author": "Tu Nombre", "author_url": "https://ejemplo.test",
"license": "Apache-2.0", "version": "1.0.0", "minimum_fossbilling_version": "0.8.0"
}
Service.php
<?php
declare(strict_types=1);
namespace Box\Mod\Miextension;
use FOSSBilling\InjectionAwareInterface;
class Service implements InjectionAwareInterface
{
protected ?\Pimple\Container $di = null;
public function setDi(\Pimple\Container $di): void { $this->di = $di; }
public function getDi(): ?\Pimple\Container { return $this->di; }
public function install(): void
{
$this->di['dbal']->executeStatement('CREATE TABLE IF NOT EXISTS `mod_miextension_note` (
`id` bigint(20) NOT NULL AUTO_INCREMENT, `client_id` bigint(20) DEFAULT NULL,
`title` varchar(255) NOT NULL, `body` text DEFAULT NULL,
`created_at` datetime DEFAULT NULL, `updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;');
$this->di['mod_service']('extension')->setConfig([
'ext' => 'mod_miextension', 'saludo' => 'Hola desde Mi Extension']);
}
public function uninstall(): void
{
$this->di['dbal']->executeStatement('DROP TABLE IF EXISTS `mod_miextension_note`');
}
public function update(array $manifest): void { /* migraciones entre versiones del modulo */ }
public function getModulePermissions(): array
{
return ['manage_settings' => [],
'view' => ['type' => 'bool', 'display_name' => __trans('Ver notas')],
'manage' => ['type' => 'bool', 'display_name' => __trans('Gestionar notas')]];
}
public function getSaludo(): string
{
return $this->di['mod_config']('miextension')['saludo'] ?? 'Hola';
}
/** HOOK: publico, estatico y primer parametro tipado \Box_Event. */
public static function onAfterClientOrderCreate(\Box_Event $event): void
{
$params = $event->getParameters();
$event->getDi()['logger']->setChannel('miextension')
->info('Nuevo pedido de cliente: #' . ($params['id'] ?? '?'));
}
}
Controller/Admin.php
<?php
declare(strict_types=1);
namespace Box\Mod\Miextension\Controller;
class Admin implements \FOSSBilling\InjectionAwareInterface
{
protected ?\Pimple\Container $di = null;
public function setDi(\Pimple\Container $di): void { $this->di = $di; }
public function getDi(): ?\Pimple\Container { return $this->di; }
public function fetchNavigation(): array
{
return ['subpages' => [['location' => 'extensions', 'index' => 2100,
'label' => __trans('Mi Extension'), 'class' => '',
'uri' => $this->di['url']->adminLink('miextension')]]];
}
public function register(\Box_App &$app): void
{
$app->get('/miextension', 'get_index', [], static::class);
$app->get('/miextension/', 'get_index', [], static::class);
$app->get('/miextension/settings', 'get_settings', [], static::class);
$app->get('/miextension/note/:id', 'get_note', ['id' => '[0-9]+'], static::class);
}
public function get_index(\Box_App $app): string
{
$this->di['is_admin_logged'];
$this->di['mod_service']('Staff')->checkPermissionsAndThrowException('miextension', 'view');
return $app->render('mod_miextension_index');
}
public function get_settings(\Box_App $app): string
{
$this->di['is_admin_logged'];
return $app->render('mod_miextension_settings');
}
// get_note(\Box_App $app, $id) recibe $app primero y luego los placeholders de la ruta.
}
fetchNavigation() lo consume Extension\Service::getAdminNavigation() y puede devolver group, para crear un grupo de primer nivel, y subpages, para colgar entradas de uno existente. El campo location de cada subpage es obligatorio: si falta, el core escribe Invalid module menu item: ... con error_log() y la ignora en silencio; si apunta a un grupo inexistente, escribe Submenu item belongs to not existing location: {$location}. Los grupos que declara el core son Activity, Client, Extension, Invoice, Order, Product, Security, Staff, Support y System, y todo se ordena por index.
Controller/Client.php es idéntico en estructura pero mucho más corto: implementa la misma interfaz, registra $app->get('/miextension', 'get_index', [], static::class); y su get_index() empieza por $this->di['is_client_logged']; —quita esa línea para permitir visitantes anónimos—. Un método de controlador puede devolver un string, que se envuelve en un Response, o directamente un Symfony\Component\HttpFoundation\Response, que ResponseFactory::normalize() respeta tal cual; para descargas hay ResponseFactory::file() y ResponseFactory::download().
Las clases de API extienden FOSSBilling\Api\AbstractApi y el nombre de la clase determina el rol: el despachador construye '\Box\Mod\' . ucfirst($mod) . '\Api\\' . ucfirst($role). El nombre del endpoint es el del método público y la URL resultante es /api/<rol>/<modulo>/<metodo>.
<?php
declare(strict_types=1);
namespace Box\Mod\Miextension\Api;
use FOSSBilling\PaginationOptions;
use FOSSBilling\Validation\Api\RequiredParams;
class Admin extends \FOSSBilling\Api\AbstractApi
{
/** GET|POST /api/admin/miextension/get_list */
public function get_list(array $data): array
{
$this->checkPermissions('miextension', 'view');
$sql = 'SELECT id, client_id, title, created_at FROM mod_miextension_note ORDER BY id DESC';
return $this->getDi()['pager']->getPaginatedResultSet($sql, [], PaginationOptions::fromArray($data));
}
#[RequiredParams(['id' => 'Falta el parametro id'])]
public function get(array $data): array
{
$this->checkPermissions('miextension', 'view');
$row = $this->getDi()['db']->getRow(
'SELECT * FROM mod_miextension_note WHERE id = :id', ['id' => (int) $data['id']]);
if (!$row) { throw new \FOSSBilling\InformationException('Nota no encontrada'); }
return $row;
}
#[RequiredParams(['title' => 'Falta el titulo'])]
public function create(array $data): int
{
$this->checkPermissions('miextension', 'manage');
$this->getDi()['db']->exec(
'INSERT INTO mod_miextension_note (title, body, created_at, updated_at) VALUES (:t, :b, NOW(), NOW())',
['t' => $data['title'], 'b' => $data['body'] ?? null]);
$id = (int) $this->getDi()['db']->getCell('SELECT LAST_INSERT_ID()');
$this->getDi()['logger']->info('Creada nota #%s', $id);
return $id;
}
// delete(array $data): bool es simetrico a create: checkPermissions 'manage' mas un DELETE.
}
El atributo RequiredParams lo procesa Dispatcher::validateRequiredParams(), que lanza InformationException con tu mensaje si el parámetro falta, es cadena vacía tras trim(), o es empty y no numérico. AbstractApi te da además $this->getService() (tu Service.php, inyectado por el despachador), $this->getMod(), $this->getIdentity() (Model_Admin, Model_Client o Model_Guest), $this->getIp() y $this->checkPermissions().
Api/Client.php y Api/Guest.php siguen el mismo patrón y caben en dos métodos: en Client, un mis_notas(array $data = []): array que filtra por $this->getIdentity()->id; en Guest, un saludo(): string que devuelve $this->getService()->getSaludo().
Advertencia de seguridad: cualquier método público de Api/Guest.php es alcanzable sin autenticación desde internet. El README del example-module lo dice: “Don’t provide confidential data over these endpoints. Anybody over the internet will be able to access these information, including bots.” Las políticas de rate limiting que contienen ese tráfico las verás en el capítulo 16.
Las plantillas van en templates/admin/ y templates/client/, nunca en html_admin/:
{# templates/admin/mod_miextension_index.html.twig #}
{% extends request.ajax ? 'layout_blank.html.twig' : 'layout_default.html.twig' %}
{% block meta_title %}{{ 'Mi Extension'|trans }}{% endblock %}
{% set active_menu = 'extensions' %}
{% block content %}
{% set notas = admin.miextension_get_list({ per_page: 20 }) %}
<table class="table">
{% for n in notas.list %}
<tr><td>{{ n.id }}</td><td>{{ n.title }}</td><td>
{% if has_permission('miextension', 'manage') %}
<a href="{{ 'miextension/delete'|api_url({ id: n.id }, 'admin') }}"
{{ fb_api_link({ modal: { type: 'confirm', title: 'Seguro?'|trans }, reload: true }) }}>{{ 'Borrar'|trans }}</a>
{% endif %}
</td></tr>
{% else %}
<tr><td colspan="3">{{ 'Sin notas'|trans }}</td></tr>
{% endfor %}
</table>
<form action="{{ 'miextension/create'|api_url({}, 'admin') }}"
{{ fb_api_form({ message: 'Nota creada'|trans, reload: true }) }}>
<input type="text" name="title" required><textarea name="body"></textarea>
<button type="submit" class="btn btn-primary">{{ 'Crear'|trans }}</button>
</form>
{% endblock %}
Los proxies guest, client y admin están inyectados como globales de Twig, así que admin.miextension_get_list(...) no es una petición HTTP: se resuelve en PHP dentro del mismo request y no pasa por CSRF. Los helpers fb_api_form(), fb_api_link() y el filtro |api_url viven en src/library/FOSSBilling/Twig/Extension/ApiExtension.php y generan el atributo data-fb-api que intercepta el wrapper JavaScript. La plantilla de ajustes tiene un efecto lateral que conviene conocer: su mera existencia hace que Module::hasSettingsPage() devuelva true y aparezca el botón de ajustes en Extensions. Su contenido es un formulario contra extension/config_save con un campo oculto ext de valor mod_miextension, precedido de {% set params = admin.extension_config_get({ ext: 'mod_miextension' }) %} para leer los valores actuales.
Activar, depurar y publicar: Extensions, PHPStan de terceros y el directorio oficial
Con los ficheros en su sitio, este es el camino hasta quedar operativo:
flowchart TD
A["Copiar la carpeta a src/modules/Miextension"] --> B{"Esta en CORE_MODULES?"}
B -- si --> C["Activo automaticamente"]
B -- no --> D["Panel admin y luego Extensions"]
D --> E["Extension Service activate"]
E --> F["Module getManifest lee manifest.json"]
F --> G["Module install invoca Service install si existe"]
G --> H["Extension status STATUS_INSTALLED y guarda version"]
H --> I["Evento onAfterAdminActivateExtension"]
I --> J["Hook Service batchConnect registra los listeners"]
J --> K["Redirect a la pagina admin si hasAdminController"]
El código que lo ejecuta, en src/modules/Extension/Service.php::activate():
case \FOSSBilling\ExtensionManager::TYPE_MOD:
$mod = $this->di['mod']($ext->getName());
$manifest = $mod->getManifest();
$this->installModule($ext);
$ext->setVersion($manifest['version'] !== null ? (string) $manifest['version'] : null);
$result['redirect'] = $mod->hasAdminController();
$result['has_settings'] = $mod->hasSettingsPage();
break;
Los tipos que conoce FOSSBilling\ExtensionManager son siete: mod, theme, payment-gateway, server-manager, domain-registrar, hook y translation. Procedimiento completo de prueba:
# 1. Copiar el modulo con el usuario del servidor web
sudo -u www-data cp -r ./Miextension /var/www/fossbilling/modules/Miextension
# 2. Activar en el panel: Extensions -> pestana de modulos -> Activate
# 3. Cron una vez, para que se registre el hook onAfterClientOrderCreate
sudo -u www-data php /var/www/fossbilling/cron.php
# 4. Comprobar que las rutas y la API guest responden
curl -s -o /dev/null -w '%{http_code}\n' https://TU_DOMINIO/miextension
curl -s "https://TU_DOMINIO/api/guest/miextension/saludo"
# 5. Verificar que el hook quedo registrado
mysql -u fossbilling -p fossbilling -e "SELECT rel_id, meta_value FROM extension_meta \
WHERE extension='mod_hook' AND rel_type='mod' AND meta_key='listener' ORDER BY rel_id;"
Gotcha del paso 3: los hooks se descubren cuando corre el cron, no al guardar el fichero. hook_batch_connect es la primera tarea de Cron\Service::runCrons(), y Hook\Service::batchConnect() también se dispara al activar la extensión vía onAfterAdminActivateExtension. Si añades un método on<Evento> a un módulo ya activo y no pasas el cron, ese hook no existe para el sistema. El subsistema completo de eventos está en el capítulo 14.
Para calidad, el proyecto usa PHPStan level 5 con un phpstan-baseline.neon para la deuda heredada. La línea de phpstan.neon que te interesa copiar es la que hace que PHPStan tolere $bean->cualquier_propiedad sin marcar error: universalObjectCratesClasses con RedBeanPHP\SimpleModel y RedBeanPHP\OODBBean. Para módulos de terceros hay además una plantilla de CI en example-module/.github/workflows/php-ci.yml, con su example-module/phpstan.neon, que analiza tu módulo contra la última release y contra las builds preview:
“As FOSSBilling evolves and matures, its internal functionality changes, which can create compatibility issues between your module and FOSSBilling. To help developers catch these issues early on, we’ve designed a workflow that enables you to perform a PHPStan analysis of your module with both the latest FOSSBilling release and its preview builds.”
En un proyecto que rompe compatibilidad en cada minor 0.x, esa doble comprobación es lo que evita que tu extensión muera con la siguiente release; el contexto sobre por qué el proyecto avanza así está en el capítulo 1. Para publicar, el portal es https://extensions.fossbilling.org/ y el core habla con una API distinta, hardcodeada en FOSSBilling\ExtensionManager::$apiUrl como https://api.fossbilling.net/extensions/v1/.
| Detalle | Valor verificado |
|---|---|
| Timeout y caché | 5 segundos por petición; respuesta cacheada 1 hora con clave extension-manager-<xxh3 del endpoint y params> |
| Query param añadido siempre | fossbilling_version |
Error si falta result en la respuesta | Invalid response from the FOSSBilling extension directory., código 746 |
| Compatibilidad | min_fossbilling_version contra Version::VERSION, solo si update_branch === 'release' |
Hay dos vías documentadas de publicación que se contradicen: el README del repositorio extension-directory describe crear un perfil de desarrollador en /account del portal y enviar la extensión a una cola de moderación; la página extensions.mdoc de la documentación dice que abras un pull request contra ese mismo repositorio. Antes de invertir tiempo, pregunta en el foro o el Discord del proyecto cuál está vigente.
Dato colateral: en un checkout de git, src/library/FOSSBilling/Version.php dice siempre VERSION = '0.0.1' porque la versión real se sustituye en tiempo de build. Como Version::isPreviewVersion() devuelve true con exactamente 0.0.1, un clon de git se comporta siempre como preview build, y eso afecta a ExtensionManager::isExtensionCompatible(). Prueba la compatibilidad sobre un ZIP de release, no sobre el repositorio.
Errores comunes y diagnóstico
| Síntoma | Causa probable | Diagnóstico o solución |
|---|---|---|
| El módulo no aparece en Extensions | falta manifest.json o la carpeta está mal capitalizada | busca Module {m} manifest file is missing or is not readable. en src/data/log/php_error.log |
Missing manifest file for the :mod module. (5897) | manifest.json ausente | crearlo en la raíz del módulo |
Module :mod does not have a service class... (5898) | falta Service.php o namespace incorrecto | revisar Box\Mod\<Ucfirst>; composer dump-autoload si tocaste src/library/ |
| 404 en todas las rutas del módulo | falta static::class en $app->get(...), o la ruta no empieza por el nombre del módulo | registerModule() solo carga el módulo del primer segmento de la URL |
| 404 en una ruta concreta que existe | placeholder con dígitos, tipo :id2, o condición regex que no casa | el patrón de nombres es @:([a-zA-Z_\-]+)@ |
| Plantilla no encontrada | plantillas en html_admin/ en vez de templates/admin/ | el TwigLoader exige que el directorio padre se llame templates |
Variable "x" does not exist | twig.strict_variables = true | usar |default(...) o {% if x is defined %} |
403 You need the "x.y" permission... | el grupo de staff no tiene el permiso, o Box_AppAdmin::checkPermission() exige hasPermission(null, $mod) | Staff → Groups → Permissions, o declarar can_always_access => true |
| El hook nunca se dispara | los listeners se descubren al ejecutar cron | php src/cron.php y revisar extension_meta con meta_key='listener' |
| El hook está en la base de datos pero no corre, o desaparece solo | el método no es public, no es static, o el primer parámetro no está tipado \Box_Event; _disconnectUnavailable() limpia los inválidos | ver Hook\Service::canBeConnected(), reactivar el módulo y pasar cron |
| Columna nueva que no se crea | RedBean corre con db.freeze = true | crearla con SQL en Service::install() |
| La entidad Doctrine nueva no se ve | caché de metadatos | el seed usa mtime y tamaño y debería invalidarse sola; si no, rm -rf src/data/cache/* |
| Índice indefinido en Pimple al paginar | usaste $di['pagination'], que no existe | la clave real es $di['pager'] |
| Funciona en local y falla en el VPS | sistema de ficheros insensible a mayúsculas en tu equipo | renombrar la carpeta a Ucfirst estricto; los comandos CLI necesitan además carpeta Commands/ y #[AsCommand], verificables con php src/console.php list |
Dónde mirar cuando nada de lo anterior encaja: src/data/log/php_error.log recoge todo lo que pasa por error_log(); el módulo Activity guarda lo que pasa por $di['logger'] gracias a Box_LogDb; los canales de Monolog son routing, event, cron y el que definas con setChannel('miextension'); y activando debug_and_monitoring.debug = true el DebugBar separa las consultas de RedBeanPHP y de Doctrine y mide registerModule, checkperm, mapping y execute. El canal event en nivel debug es el más útil mientras desarrollas hooks: Box_EventManager::fire() escribe Fired event: <nombre> con el payload completo.
Lo que queda operativo
Tienes el mapa interno de FOSSBilling 0.8.5: qué componentes son de Symfony y cuáles propios, cómo se resuelve una ruta desde el rewrite hasta el Response, qué te da el contenedor Pimple y cómo llega a tus clases sin autowiring, y por qué hay dos ORM compartiendo tablas con instrucciones explícitas de usar solo uno en código nuevo. Y tienes un módulo real activado: carpeta Ucfirst, manifest.json válido, Service.php con instalación idempotente y permisos declarados, controladores registrando rutas con static::class, API en los tres roles con validación declarativa, plantillas en templates/ y un hook registrado tras pasar el cron. Falta todo lo que ese módulo puede hacer hacia fuera. En el capítulo 14 recorrerás la API JSON completa —los tres roles enrutables, la autenticación por HTTP Basic y por sesión con CSRF, el catálogo de códigos de error y el rate limiting— y el sistema de hooks con sus 130 eventos y la tabla extension_meta donde viven los listeners.