Modular monolith PHP framework. Symfony components under the hood. Vertical Slice Architecture. JS modules per controller.
nqphp is now a pure framework package — only src/Core/ ships
in this repo. Concrete applications (controllers, commands, jobs,
middleware, JS modules) live in separate application repositories
that composer require nqai-dev/nqphp. The canonical reference
scaffold lives in
nqai-dev/nqphp-template
and demonstrates the framework's conventions.
What ships in this repo:
src/Core/Attribute/—#[Controller],#[Route],#[AsCommand],#[Middleware],#[Schedule]plus more Phase 2 attributessrc/Core/Routing/— route auto-discovery + named groupssrc/Core/Kernel/— minimal HTTP kernel; runs discovered middlewares around route dispatchsrc/Core/Console/— Symfony Console wrapper +CommandDiscoverersrc/Core/Middleware/— auto-discovery + Kernel invocationsrc/Core/Scheduler/—#[Schedule]attribute + Symfony Scheduler wiringsrc/Core/Config/— per-featureconfig.phpreader +Kernel::config()src/Core/Js/— JS-module runtime helper + per-featureclient.jsES-module servingsrc/Core/Security/— double-submit-cookie CSRF (CsrfTokenManager)src/Core/Controller/— base class withrender / redirect / jsonsrc/Core/Entity/— custom ORM (#[Id]/#[Column]/#[Where],EntityManager, pluggableDriverInterfacewithInMemoryDriverSqliteDriverPDO-backed)
-
Framework + apps are separate repos. Framework code ships here as a Composer package; consumer apps live in their own repos with their own
composer.jsonthatrequiresnqai-dev/nqphp. The scaffold reponqphp-templatedemonstrates the convention end-to-end. -
Vertical Slice Architecture. Each feature in a consumer app is a directory under
src/Feature/{Name}/containing its ownController/,Command/,Middleware/,Scheduler/, andconfig.php. The framework discovers these at boot via reflection. A feature is the unit of isolation and (eventually) extraction. -
Symfony components as building blocks.
symfony/routing,symfony/http-kernel,symfony/console,symfony/dependency-injection,symfony/scheduler. Notframework-bundle— too opinionated. We glue components together. -
Custom ORM, no Doctrine.
#[Id],#[Column],#[Where],EntityManager(Symfony-stylefindBy/findOneBy/count/persist), and a pluggableDriverInterfacewith two shipped implementations:InMemoryDriver(array-backed, ephemeral, fast) andSqliteDriver(PDO + SQLite, persistent, the production choice). Doctrine was rejected because the framework's reflection- attribute model is enough for the project's persistence needs without the bundle weight.
-
Auto-discovery via attributes.
#[Controller]on the class,#[Route]on the method. No route files.#[AsCommand]for CLI.#[Middleware]for cross-cutting concerns.#[Schedule]for periodic tasks.composer scriptsfor cs-fixer style checks. -
Named routes. Every route has a name like
admin:post:listderived from the#[Controller]prefix. Manage thousands of routes viabin/console routes:listand per-prefix filters. -
JS modules per controller. Each controller directory can contain a
client.jsthat the framework serves as an ES module at/_nqphp/js/{Name}/{file}.js. HTML works without JS; the module progressively enhances interaction. No SPA framework. -
DX is a product.
bin/devorchestratescomposer install+ PHP built-in server.composer cs:check/composer cs:fixenforce PSR-12 via php-cs-fixer (CI step). -
MIT license.
composer require nqai-dev/nqphp:^0.2Then in your public/index.php:
require __DIR__ . '/../vendor/autoload.php';
use Nqphp\Core\Kernel\Kernel;
use Symfony\Component\HttpFoundation\Request;
$kernel = new Kernel(dirname(__DIR__));
$kernel->handle(Request::createFromGlobals())->send();In your bin/console:
require __DIR__ . '/../vendor/autoload.php';
use Nqphp\Core\Kernel\Kernel;
use Nqphp\Core\Console\CommandDiscoverer;
use Nqphp\Core\Scheduler\ScheduleDiscoverer;
use Nqphp\Core\Middleware\MiddlewareDiscoverer;
use Symfony\Component\Console\Application;
$projectDir = dirname(__DIR__);
$kernel = new Kernel($projectDir);
$cmdDiscoverer = new CommandDiscoverer([$projectDir . '/src/Feature', $projectDir . '/src/Core']);
$schedDiscoverer = new ScheduleDiscoverer([$projectDir . '/src/Feature', $projectDir . '/src/Core']);
$mwDiscoverer = new MiddlewareDiscoverer([$projectDir . '/src/Feature', $projectDir . '/src/Core']);
$app = new Application('myapp', '0.1.0');
$app->add(new \Nqphp\Core\Routing\RoutesListCommand($kernel));
$app->add(new \Nqphp\Core\Scheduler\ScheduleListCommand($schedDiscoverer));
$app->add(new \Nqphp\Core\Scheduler\ScheduleRunCommand($schedDiscoverer));
$app->add(new \Nqphp\Core\Config\FeatureListCommand($kernel));
foreach ($cmdDiscoverer->discover() as $cmd) { $app->add($cmd); }
$app->run();See nqphp-template for the full scaffolded bin/console and
public/index.php ready to extend.
bin/console routes:list List all auto-discovered HTTP routes.
bin/console schedule:list List all #[Schedule]-annotated tasks.
bin/console schedule:run Run schedules whose cron is due now.
bin/console feature:list Per-feature inventory: config + routes + middlewares.
bin/console entity:list List all #[Entity]-discovered domain entities.
bin/console entity:show <name> Show detailed info (#[Id] + #[Column]) for one entity.
bin/console list Show Symfony Console's auto-generated help.
(hello:greet and any other feature commands are not shipped by
the framework — they live in your app's src/Feature/{Name}/Command/
and are discovered at boot.)
The framework ships a small, custom ORM under src/Core/Entity/ —
no Doctrine dependency. Three attributes + one manager + one
pluggable driver:
namespace App\MyApp\Entity;
use Nqphp\Core\Attribute\Column;
use Nqphp\Core\Attribute\Entity;
use Nqphp\Core\Attribute\Id;
use Nqphp\Core\Attribute\Where;
// `name` is the canonical handle (used in EntityDiscoverer map, CLI
// introspection). `table` is the physical SQL table name — defaults to
// `name` when omitted. Override `table` for legacy DB integration or
// multi-tenant schemas where handle and physical table differ.
#[Entity(name: 'user', table: 'app_users')]
final class User
{
#[Id]
public ?int $id = null;
// UNIQUE constraint enforced by SqliteDriver (Phase 2 #10 extension):
// duplicates raise PDOException "UNIQUE constraint failed: user.email".
#[Column(name: 'email', length: 255, unique: true)]
public string $email = '';
#[Column(name: 'created_at')]
public \DateTimeImmutable $createdAt;
// #[Where] marks the property as filterable with the named SQL
// operator; the actual operator at query-time is taken from the
// criterion shape passed to findBy/findOneBy/count.
#[Where(operator: 'LIKE')]
public string $displayName = '';
}Then in your application code:
$em = $kernel->entityManager();
// Persist (Unit-of-Work — flush() is currently a no-op until the SQL
// driver becomes the default; in-memory and Sqlite drivers persist
// immediately on persist()).
$alice = new User();
$alice->email = 'alice@example.com';
$alice->createdAt = new \DateTimeImmutable();
$alice->displayName = 'alice';
$em->persist($alice);
// Symfony-style query DSL + remove (Phase 2 #11)
$em->findAll(User::class);
$em->remove($alice); // returns true once, false on idempotent re-call
$em->findBy(User::class, ['email' => 'alice@example.com']);
$em->findOneBy(User::class, ['email' => 'alice@example.com']);
$em->count(User::class);
// Operator-aware criteria (Phase 2 #10)
$em->findBy(User::class, ['displayName' => ['LIKE' => 'ali%']]);
$em->findBy(User::class, ['id' => ['IN' => [1, 2, 3]]]);
$em->findBy(User::class, ['email' => ['BETWEEN' => ['a@x.com', 'z@x.com']]]);
$em->findBy(User::class, ['createdAt' => ['>=' => '2024-01-01']]);
// count() accepts the same operator-aware criteria
$em->count(User::class); // total
$em->count(User::class, ['email' => ['LIKE' => '%@x.com']]);
$em->count(User::class, ['id' => ['IN' => [1, 2, 3]]]);The default driver is SqliteDriver (in-memory when no
NQPHP_SQLITE_PATH is set, file-backed otherwise). Override at
boot:
use Nqphp\Core\Entity\EntityManager;
use Nqphp\Core\Entity\Driver\SqliteDriver;
use Nqphp\Core\Entity\Driver\InMemoryDriver;
// File-backed SQLite
$em = new EntityManager(
$kernel->entityDiscoverer(),
new SqliteDriver(new \PDO('sqlite:' . __DIR__ . '/var/data.db'))
);
// In-memory (tests, one-shot CLI runs)
$em = new EntityManager(
$kernel->entityDiscoverer(),
new InMemoryDriver()
);Both drivers implement Nqphp\Core\Entity\Driver\DriverInterface,
so swapping is a one-line change at the wiring layer.
#[Column] supports metadata that SqliteDriver::ensureSchema()
translates into SQL DDL:
| Column attribute | SQLite DDL |
|---|---|
#[Column(type: 'string')] |
TEXT |
#[Column(type: 'string', length: 255)] |
TEXT (length is application-side, SQLite ignores) |
#[Column(type: 'integer')] |
INTEGER |
#[Column(type: 'float')] |
REAL |
#[Column(type: 'boolean')] |
INTEGER (0/1) |
#[Column(type: 'datetime')] |
TEXT (ISO-8601) |
#[Column(type: 'json')] |
TEXT (JSON-encoded) |
#[Column(nullable: true)] |
column allows NULL |
#[Column(nullable: false)] |
NOT NULL (the default) |
#[Column(unique: true)] |
NOT NULL UNIQUE (Phase 2 #10 extension) |
#[Column(default: 'pending')] |
DEFAULT 'pending' (string with quote escape) |
#[Column(default: 0)] |
DEFAULT 0 |
#[Column(default: true)] |
DEFAULT 1 (SQLite has no native bool) |
#[Column(default: null)] |
no DEFAULT clause (default behavior) |
Multiple #[Column(unique: true)] in the same entity become multiple
UNIQUE constraints (enforced independently by SQLite). Combined with
#[Id] as primary key, this gives a complete schema layer without
writing migration files.
String defaults are SQL-escaped per the SQL standard: a single quote
inside a literal renders as '' (two single quotes). This is the
only safe way to embed user-controlled strings in DDL because DDL
does not go through PDO prepared statements. Phase 2 #10 extension.
InMemoryDriver ignores schema entirely (no-op) — only SqliteDriver
generates DDL. Schema is dropped and re-created on each
ensureSchema() call; real migrations (alter-table, add-column
without data loss) come in a Phase 2 follow-up.
Doctrine is a full-featured ORM with bundles, annotations, schema
migrations, and an entity manager optimized for hundreds of mapped
classes. nqphp boots a single SQLite file or an in-memory array
and serves a few dozen entities per project. The reflection +
attribute model that already drives routing, middlewares, and
scheduling fits this footprint without Doctrine's overhead. If a
project needs Doctrine's query builder, unit-of-work, or
DBAL-style portability, swap the EntityManager wiring for
Doctrine\ORM\EntityManager — the framework doesn't lock you in.
- ✅
#[AsCommand]CLI command auto-discovery. - ✅ JS-module runtime helper — framework
csrf(),fetchJson(), per-featureclient.jsserving. - ✅ Middleware pipeline —
#[Middleware]attribute + auto-discovery- Kernel runtime invocation + tests.
- ✅
#[Schedule]cron + Symfony Scheduler integration — discover + list + run via the real Symfony Scheduler evaluator. - ✅ Per-feature
config.phpautoloader — typed config reads +Kernel::config(string $feature, string $key, $default). - ✅
bin/console feature:list— per-feature inventory. - ✅ PHP CS Fixer —
composer cs:check/cs:fix, CI step. - ✅ Custom ORM —
#[Id],#[Column],EntityManagerwithfindBy/findOneBy/count/persist, plusDriverInterfacewithInMemoryDriver(array) +SqliteDriver(PDO + SQLite). - ✅
#[Column(unique: true)]— UNIQUE column-constraint в SQLite schema (Phase 2 #10 extension). - ✅
#[Column(default: ...)]— DEFAULT clause для SQLite (Phase 2 #10 extension). String values SQL-escaped ('→''), booleans → 1/0, numeric as-is, null/no-arg → no DEFAULT clause. - ✅
#[Entity(name: ..., table: ...)]— custom physical SQL table name override (Phase 2 #10+ extension). Defaults tonamefor backward compat; useful для legacy DB integration та multi-tenant schemas де canonical handle і physical table розходяться. - ✅
#[Where]attribute + operator-aware criteria —LIKE,IN,BETWEEN,>=,<=,!=, etc. on top of the exact-match API. - ✅
bin/console entity:list+entity:show <name>— entity discovery reports and single-entity detail view. - ✅ Kernel wires
SqliteDriveras default withNQPHP_DRIVER=memoryenv override falling back toInMemoryDriver. - ✅
EntityManager::remove()(Phase 2 #11) — idempotent delete by#[Id], throws on never-persisted entities. Mirrorspersist()symmetry; cascade is opt-in via explicit calls (callers control what gets removed when a parent goes away).
Open Phase 2 follow-ups (not yet shipped, lower priority):
- Full Symfony DI integration (per-feature
services.yamlwith actual service definitions; current per-featureconfig.phpis the data-only half). - Real schema migrations (the current
SqliteDriver::ensureSchema()drops-and-recreates — fine for Phase 1 but not safe for production data). EntityManager::flush()becomes a real barrier once multiple persists per request are common (currently each persist() writes immediately).
MIT. See LICENSE.