modufolio / appkit
A lean PHP framework built on Symfony components and Doctrine ORM, with hand-wired DI, attribute routing, and first-class Inertia.js support
Requires
- php: ^8.4
- ext-curl: *
- ext-dom: *
- ext-fileinfo: *
- ext-intl: *
- ext-libxml: *
- ext-pdo: *
- ext-simplexml: *
- ext-sqlite3: *
- ext-zip: *
- doctrine/dbal: ^4
- doctrine/migrations: ^3.6
- doctrine/orm: ^3
- endroid/qr-code: 6.0.9
- firebase/php-jwt: ^7.0
- laminas/laminas-escaper: ^2.18
- modufolio/http: ^0.2
- nikic/php-parser: ^5.6
- psr/clock: ^1.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
- psr/log: 3.0
- spomky-labs/otphp: ^11.3
- symfony/cache: ^7.4
- symfony/clock: ^7.4
- symfony/config: ^7.4
- symfony/console: ^7.4
- symfony/filesystem: ^7.4
- symfony/http-foundation: ^7.4
- symfony/password-hasher: ^7.4
- symfony/process: ^7.4
- symfony/property-access: ^7.4
- symfony/property-info: ^7.4
- symfony/routing: ^7.4
- symfony/serializer: ^7.4
- symfony/stopwatch: ^7.4
- symfony/uid: ^7.4
- symfony/validator: ^7.4
- symfony/var-exporter: ^7.4
- symfony/yaml: ^7.4
- willdurand/negotiation: ^3.1
Requires (Dev)
- brianium/paratest: ^7.8
- claviska/simpleimage: ^4.0
- doctrine/data-fixtures: ^2.2
- fakerphp/faker: ^1.24
- friendsofphp/php-cs-fixer: ^3.64
- modufolio/json-api: ^0.9.0
- phpstan/phpstan: ^2.2
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^10.5 || ^11
- symfony/dependency-injection: ^7.4
Suggests
- ext-exif: Required for image processing — reading EXIF metadata and auto-orienting photos
- ext-gd: Required for image processing — resizing, cropping and converting images
- claviska/simpleimage: Required for image processing — the GD-based manipulation backend (^4.0)
- symfony/dependency-injection: To declare services with the Symfony container and treat modules as bundles (Kernel::configureContainer())
Provides
None
Conflicts
None
Replaces
None
README
A PHP framework for security-conscious SaaS applications, built from Symfony components and Doctrine ORM around a kernel you can read end to end. Routing, firewalls, authenticators, forms, Inertia, modules, image processing, and a console with generators are all here. What is missing is the machinery: no compiled container, no event dispatcher, no bundles, no bootstrap you have to regenerate.
In AppKit, your App class is the container. Most frameworks either
generate a container class you never read or hide one behind static
accessors. Here the container is a class you write: services are typed
methods on your App, lazily constructed and cached in properties you can
see. There is nothing to compile, because you already wrote what a compiler
would generate, and when you want to know where a service comes from, you
open the file and read it.
The same rule holds for everything else the kernel does. Authentication is a
method you can step through, not a chain of listeners. Configuration is a PHP
file that is required, not a tree that is merged and cached. When something
goes wrong, the answer is in a stack trace, not in a compiler pass.
Hand-wiring has a ceiling, and AppKit does not pretend otherwise. When the service graph outgrows it, the application puts Symfony's DI container behind the kernel with one line: autowired trees, tags, compiler passes, modules that ship their own definitions. The kernel keeps first say on every id it declares; only an unknown id reaches Symfony. Nothing you wrote by hand has to be thrown away. See The Symfony container behind the kernel.
Why it exists
A SaaS application needs the same things on day one: an ORM, validation, sessions, CSRF protection, firewalls, remember-me, brute-force limits, a password hasher that does not leak timing. Assembling these from individual packages means every team makes the same fifty decisions, slightly differently, and gets a few of them slightly wrong. Reaching for a full-stack framework gets the decisions made, but wrapped in a compiled container, an event dispatcher, a plugin system, and a generated bootstrap that exist to manage the framework's own size rather than yours.
AppKit is the middle that was missing. The components are Symfony's and
Doctrine's, mature and maintained by people who do that for a living. The
security model is the one those components were designed around, wired and
validated at boot. What is left out is every layer whose only job is to hide
the wiring, so the file you read is the resolution path that runs. Tooling
that earns its keep, such as a make:entity generator ported from
MakerBundle,
comes along; tooling that exists to manage indirection does not.
What AppKit deliberately doesn't include
Each of these is a stated choice with a documented alternative, not a gap:
- No application-level event bus. Extension happens through named seams:
explicit interfaces (authenticators, user checkers, CSRF validators,
package contracts answered in
config/services.php), Doctrine's lifecycle events at the persistence layer, and plain method override — subclass yourAppand replace an accessor. Internal control flow stays a readable call stack. - No queue abstraction. Background jobs run on RoadRunner's first-party jobs plugin — you are already running RoadRunner, and durability is a config swap, not a PHP layer. See Background jobs.
- No mailer, no i18n. Bring the PSR-compatible library your app needs and
register it as an
Appmethod; the framework does not wrap what it cannot improve. - No container-coupled console. The framework ships console commands,
not a
bin/console; the runner is application code. The RoadRunner reference (modufolio/appkit-roadrunner) boots its console without the app container, so a wiring bug can never take down the tool that fixes it; the skeleton'sConsoleRunnerconstructs each command by hand for the same reason. See Console. - Security headers live at the edge (nginx/Caddy/CDN), where they also cover static assets — see What the framework does not handle.
What it solves
- Fast boot. No DI compile step, no cache invalidation, by default.
Config files are loaded with
require; OPcache handles the rest. The opt-in Symfony container is compiled per boot outside prod and dumped once in prod. - Transparent control flow. No event dispatcher by design. Reading
handleAuthentication()top-to-bottom shows exactly what runs. - RoadRunner-aware. Every stateful service implements
ResetInterface; the kernel rebuilds itsApplicationStateInterfacestate (ApplicationState) per request. The worker loop stays in your application rather than behind a runtime — see modufolio/appkit-roadrunner. - Security hardening already wired. Symfony-style firewalls with
method/host/IP restrictions; path- and attribute-based access control with a
role hierarchy and trust-level attributes (
IS_AUTHENTICATED_FULLY,IS_IMPERSONATOR, …); CSRF rotation on login; session-fixation defence; remember-me with optional persistent tokens (theft detection and rotation); HTTPS channel upgrades; brute-force protection; a token unserialize allowlist; password timing-parity; credential-length DoS caps; and boot-time firewall-config validation. - Strict typing. PHP 8.4+,
declare(strict_types=1)throughout. The PSR-7 implementation ismodufolio/http, a separate package the framework depends on: a strict-typed fork ofnyholm/psr7under theModufolio\Psr7\Httpnamespace.
Quick start
composer create-project modufolio/appkit-skeleton my-app
cd my-app
composer start
The skeleton lives in its own repository:
modufolio/appkit-skeleton.
composer start is the skeleton's Composer script — PHP's built-in server on
port 8000 with router.php — not a framework command.
A minimal controller
<?php declare(strict_types=1); namespace App\Controller; use Modufolio\Appkit\Core\AbstractController; use Modufolio\Psr7\Http\Response; use Psr\Http\Message\ResponseInterface; use Symfony\Component\Routing\Attribute\Route; final class HelloController extends AbstractController { #[Route('/hello/{name}', methods: ['GET'])] public function show(string $name): ResponseInterface { return Response::json(['message' => "Hello, {$name}"]); } }
AbstractController is a concrete base class despite its name — it is not
declared abstract. The kernel fills its protected properties
(entityManager, tokenStorage, urlGenerator, userProvider, validator,
flashBag) right after construction. Extending it is optional: any class the
router resolves works, and only subclasses get that treatment.
Documentation
Full guides under docs/:
- Getting started — install, configure, and run your first app
- Kernel — request lifecycle, service container, boot
- Routing — routes, parameters, access control
- Controllers — controllers and parameter attributes
- Inertia — returning Inertia pages, the renderer, the module
- Dependency injection — wiring services with config files
- Modules — self-contained feature packages: manifest, conventions, lifecycle
- Templates — layouts, snippets, sections, asset helpers
- Security — firewalls, access control, CSRF, roles, trust levels
- Authenticators — form login, JWT, OAuth 2.1, 2FA, remember-me, brute-force
- Database — Doctrine ORM, QueryBuilder, pagination, soft delete
- Forms — validation,
ValidationResult, payload mapping - Exception handling — turning exceptions into HTTP responses
- File uploads — validating and storing uploaded files
- Image processing — Darkroom, Dimensions, DiskManager
- Console — built-in commands (
debug:firewall,security:validate,make:entity), writing your own - Toolkit — array, file, string, and directory utilities
- Testing — PHPUnit, EntityFactory, static analysis
- Deployment — Nginx/Caddy, permissions, RoadRunner, databases
- Configuration — environment variables and config reference
Start with the introduction for the architecture overview and the design philosophy the rest of the documentation assumes.
Requirements
- PHP 8.4 or later
- Composer
- Extensions:
curl,dom,fileinfo,intl,libxml,pdo,simplexml,sqlite3,zip - Optional, for image processing:
exif,gdand theclaviska/simpleimagepackage — listed undersuggestincomposer.json, not required
The Symfony components are pinned to the current LTS line (^7.4, supported
until late 2028), not to the newest release. The PHP floor is raised when the
language adds something the kernel wants — property hooks and asymmetric
visibility drove 8.4 — but the Symfony constraint only moves when the next
LTS ships. Interim releases such as 8.0 and 8.1 have a support window of eight
months and are never targeted; hosts that pin one gain nothing from the kernel
and lose the longer support.
See composer.json for the canonical dependency list.
License
MIT. See LICENSE.