Search by

modufolio / appkit

modufolio

A lean PHP framework built on Symfony components and Doctrine ORM, with hand-wired DI, attribute routing, and first-class Inertia.js support

Package info

github.com/modufolio/appkit

pkg:composer/modufolio/appkit

Statistics

Installs: 386

Dependents: 4

Suggesters: 0

Stars: 0

Open Issues: 0

v0.21.0 2026-09-15 09:29 UTC

README

CI PHPStan License: MIT codecov

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 your App and 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 App method; 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's ConsoleRunner constructs 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 its ApplicationStateInterface state (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 is modufolio/http, a separate package the framework depends on: a strict-typed fork of nyholm/psr7 under the Modufolio\Psr7\Http namespace.

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, gd and the claviska/simpleimage package — listed under suggest in composer.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.