ppi/framework
PPI Framework is a modular PHP web application framework built on Symfony components. It provides MVC architecture, dependency injection, and flexible configuration, emphasizing reusable modules and clean separation of concerns for maintainable web apps and APIs.
Before touching code, calibrate expectations: ppi/framework is not Laravel and not a Laravel-adjacent tool. It's a 2012–2016 era MVC/HMVC engine that wires Zend Framework 2 modules together with Symfony Routing + EventDispatcher, Zend\ServiceManager as the DI container, and Doctrine ORM for persistence. The last tagged release is Feb 2016, so the first real task is proving it runs at all on your machine.
Step 0 — Pin a runtime. Do not attempt this on PHP 8. Use Docker with php:5.6-cli or php:7.1-cli, plus the matching ZF2/Doctrine versions. Commit composer.lock immediately; a bare composer update will pull in dependency majors that the engine was never written against.
Step 1 — Get a skeleton, not the engine alone. ppi/framework is the kernel; the day-to-day code lives in a project skeleton (ppi/skeleton historically). Pull that, then composer install --prefer-dist. Point your vhost document root at public/ and make app/cache writable.
Step 2 — Read four files in this order:
public/index.php — the front controller; this is your entire bootstrap story (App instantiation, config dir, run()).app/config/config.yml — global parameters, service manager wiring, develop_mode.app/config/routing.yml — route name → pattern → _controller.Module.php under app/modules/ (or module/) — this is the unit of composition, mirroring ZF2's getConfig() / getAutoloaderConfig() / onBootstrap() contract.Step 3 — First use case: a JSON endpoint. Add a route in routing.yml, point _controller at a FooController::indexAction in an existing module, and have the action write JSON straight onto the response:
// app/config/routing.yml
api_status:
pattern: /api/status
defaults: { _controller: 'App\Controller\StatusController::indexAction' }
// module/App/src/App/Controller/StatusController.php
public function indexAction()
{
$this->getResponse()->setHeader('Content-Type', 'application/json');
return $this->getResponse()->setContent(json_encode(['ok' => true]));
}
That one loop — route → controller → service → response — is 80% of what you'll do. Everything else is wiring the ServiceManager and Doctrine.
Module-per-bounded-context. The Module class is your service provider equivalent. Config returned from getConfig() is merged into the global tree, so register factories there rather than reaching into globals:
public function getConfig()
{
return [
'service_manager' => [
'factories' => [
'acme.mailer' => function ($sm) {
return new Mailer($sm->get('acme.transport'));
},
],
'aliases' => [
'Acme\Mailer' => 'acme.mailer',
],
],
'routes' => require __DIR__ . '/config/routes.php',
];
}
Prefer named factories with explicit dependencies over invokables — the ServiceManager is a string-keyed bag, and implicit wiring makes it impossible to see what a controller actually depends on.
Controllers stay thin. Extend the framework's abstract controller, pull collaborators from the ServiceManager, and delegate. Use $this->render('module/view/script', $params) for HTML; return the response object directly for JSON/redirects. Controllers can also be wired as services themselves — do that when they have more than one or two dependencies.
Closures for micro-endpoints, classes for anything real. The router accepts Closures as _controller, which is great for a health check or webhook and terrible for anything else — closures aren't serialisable, so they interact badly with config caching and are invisible to grep.
Layer configuration, don't fork it. Global config.yml for shared parameters, a per-module config for module-local wiring, and a git-ignored local override for credentials. Treat the merged tree as immutable at runtime; anything environment-specific belongs in a parameter, injected into a factory.
Doctrine: entity → repository → service. Keep the EntityManager out of controllers. Expose repositories as services ('acme.repo.order' => function ($sm) { return $sm->get('doctrine.em')->getRepository('App\Entity\Order'); }) so controllers depend on a domain-facing interface. Wrap multi-step writes in $em->getConnection()->transactional(...); there is no Eloquent-style mass assignment safety net, so validate and hydrate explicitly.
Use the event dispatcher for cross-cutting concerns. Register listeners in onBootstrap() against the dispatch/render events for auth, request logging, response caching, and exception formatting. This is the closest thing the stack has to middleware, and it keeps those concerns out of every action.
Console for operational work. Register commands in module config and drive them from the skeleton's console entry point. This is where cache warmup, proxy generation, and data migrations should live — you will be running them constantly.
Testing. Boot the kernel in a test bootstrap, then write functional tests that dispatch a request through it and assert on the response; use $app->getServiceManager() to swap in fakes. For unit tests, plain PHPUnit against your own classes is fine because, unlike Laravel, there's no facade magic to stub.
Operational integration. Wrap install, cache clear, cache warm and proxy generation into a single Makefile target or a compose exec script. Every deploy must clear app/cache — a stale merged-config cache is the #1 source of "my change did nothing" tickets.
The dependency tree is the product risk. ZF2 2.x, Symfony 2.x routing/event-dispatcher, and Doctrine ~2.4 are all long past end-of-life. composer update is a landmine: pin everything, commit the lock file, and if you must, use replace/conflict entries to stop transitive resolution from creeping. Budget for CVEs — treat the app as an internal network service behind a gateway, and keep an audit of which Zend components are actually loaded.
PHP 8 will fatal. Between removed each(), curly-brace string offsets, and Zend\ServiceManager internals relying on old reflection/__call behaviour, expect errors at bootstrap. Containerise at 7.1 and stop there; a compatibility fork is not worth the maintenance unless the app is a long-term asset.
YAML config fails silently. Bad indentation, a duplicate key, or a parameters: block in the wrong place produces a merged tree that's subtly wrong rather than an error. When behaviour is inexplicable, dump the merged config from the container and diff it against what you expected — do that before reading any application code.
Cache invalidation is manual. There is no cache:clear artisan command; you rm -rf app/cache/* (config cache, annotation/proxy cache, view cache). Forget it after editing routing or module config and you'll debug a phantom for an hour.
_controller strings are resolved late and untyped. A typo surfaces as a 500 at dispatch, not a 404 at boot. Add a small console command that iterates routes and asserts each controller resolves; it pays for itself the first week.
Service names are case-sensitive strings. $sm->get('acme.Mailer') and 'acme.mailer' are different services. Standardise on class-name constants (Mailer::class) for aliases so refactors are caught by static analysis instead of at runtime.
Closure controllers + config caching = broken. If you cache merged config, closures can't be exported. Keep closures only for routes you never cache, or convert them to invokable classes.
Doctrine proxies need write access and generation. Set dev mode on locally (develop_mode: true) so proxies regenerate, but generate them explicitly during deploy — a read-only filesystem plus missing proxies is a confusing, intermittent failure.
No env() helper, no .env loading. Credentials go in a git-ignored local config fragment, loaded last so it wins the merge. Also note that config values are strings from YAML unless you cast them; a debug: false that arrives as "false" is truthy.
Listener ordering matters. onBootstrap() fires before dispatch; a listener registered with a low priority may run after the response is already populated. Always set explicit priorities on listeners whose order you depend on.
Extension points worth knowing: custom view helpers (register via the view helper manager), custom route types when routing.yml patterns can't express a constraint, module-manager listeners for cross-module bootstrapping, and the dispatch event for anything middleware-shaped. Those four cover nearly every "the framework doesn't do X" problem you'll hit.
Plan the exit while you're inside. Module boundaries map cleanly onto Laravel service providers or Symfony bundles, and the event-dispatcher wiring maps onto HTTP middleware. If you keep controllers thin and services container-agnostic (constructor injection, no $sm inside domain classes), a future migration becomes mechanical instead of a rewrite — which is the single most valuable architectural decision you can make on a framework that hasn't shipped since 2016.
How can I help you explore Laravel packages today?