Skip to content
NQAI-DevPublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

297 Commits

Folders and files

Repository files navigation

nqphp

Modular monolith PHP framework. Symfony components under the hood. Vertical Slice Architecture. JS modules per controller.

Status: pre-alpha Phase 2

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 attributes
  • src/Core/Routing/ — route auto-discovery + named groups
  • src/Core/Kernel/ — minimal HTTP kernel; runs discovered middlewares around route dispatch
  • src/Core/Console/ — Symfony Console wrapper + CommandDiscoverer
  • src/Core/Middleware/ — auto-discovery + Kernel invocation
  • src/Core/Scheduler/ — #[Schedule] discovery, listing, and CLI runner
  • src/Core/Config/ — per-feature config.php reader + Kernel::config()
  • src/Core/Js/ — JS-module runtime helper + per-feature client.js ES-module serving
  • src/Core/Security/ — double-submit-cookie CSRF (CsrfTokenManager)
  • src/Core/Controller/ — base class with render / redirect / json
  • src/Core/Entity/ — custom ORM (#[Id] / #[Column] / #[Where], EntityManager, pluggable DriverInterface with InMemoryDriver
    • SqliteDriver PDO-backed)
  • src/Core/View/ — View engine (ViewRenderer, TemplateEngine), scoped CSS compilation (ScopedCss, ScopedCssCompiler), RouteAssetManager (manifest integration & HTTP headers for nq.js)
  • src/Core/Migration/ — database migrations engine (Migrator, MigrationRepository, AbstractMigration)
  • src/Core/Queue/ — lightweight background queue (DatabaseQueue, Worker, JobInterface)
  • src/Core/Cache/ — cache stores and PSR-compatible interfaces
  • src/Core/Validation/ — input validation attributes and validator
  • src/Core/I18n/ — translation and localization engine

Architecture decisions

  • Framework + apps are separate repos. Framework code ships here as a Composer package; consumer apps live in their own repos with their own composer.json that requires nqai-dev/nqphp. The scaffold repo nqphp-template demonstrates the convention end-to-end.

  • Vertical Slice Architecture. Each feature in a consumer app is a directory under src/Feature/{Name}/ containing its own Controller/, Command/, Middleware/, Scheduler/, and config.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, and symfony/event-dispatcher. Not framework-bundle — too opinionated. We glue components together. The schedule runner currently evaluates cron expressions with dragonmantank/cron-expression directly.

  • Custom ORM, no Doctrine. #[Id], #[Column], #[Where], EntityManager (Symfony-style findBy / findOneBy / count / persist), and a pluggable DriverInterface with two shipped implementations: InMemoryDriver (array-backed, ephemeral, fast) and SqliteDriver (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 scripts for cs-fixer style checks.

  • Named routes. Every route has a name like admin:post:list derived from the #[Controller] prefix. Manage thousands of routes via bin/console routes:list and per-prefix filters.

  • JS modules per controller. Each controller directory can contain a client.js that 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/dev orchestrates composer install + PHP built-in server. composer cs:check / composer cs:fix enforce PSR-12 via php-cs-fixer (CI step).

  • MIT license.

Installation (in a consumer app)

composer require nqai-dev/nqphp:^0.2

Then 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.

Available CLI commands (built-in)

Introspection & Inspection

bin/console routes:list          List all auto-discovered HTTP routes.
bin/console schedule:list       List all #[Schedule]-annotated tasks with their next run time.
bin/console schedule:run        Run schedules whose cron is due now.
bin/console feature:list        Per-feature inventory: config + routes + middlewares.
bin/console feature:show <name> Show detailed feature config, routes, middleware, commands.
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 service:list        List all discovered container services.
bin/console system:health       Run system and environment health checks.
bin/console list                Show Symfony Console's auto-generated help.

Code Generators (Vertical Slice Scaffolding)

bin/console make:feature <name>             Scaffold full vertical feature module (Controller, Service, Entity, View).
bin/console make:controller <feature> <name> Create a new Controller in a feature module.
bin/console make:entity <feature> <name>     Create a new #[Entity] with table mapping.
bin/console make:migration <name>            Create a new timestamped database migration.

Database & Migrations

bin/console migrate              Run all pending database migrations in transaction.
bin/console migrate:rollback     Rollback the last executed database migration batch.

Background Workers, Caching & Assets

bin/console queue:work           Process queue jobs (supports --once and --sleep=<seconds>).
bin/console cache:clear          Clear application and framework cache stores (alias: cc).
bin/console assets:build         Compile and bundle scoped CSS/JS with manifest generation.

schedule:list supports --raw (TSV output for scripts) and --timezone=Europe/Berlin (override the NEXT RUN wall-clock zone). schedule:run supports --force (fire every schedule regardless of cron), --dry-run (preview only), --now=... (override the reference time for tests / back-fills) and --lock=PATH (advisory flock so two cron workers cannot double-fire in the same minute).

(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.)

Custom ORM

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]]]);

Choosing a driver

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. EntityManager maps property names to #[Column(name: ...)] names, serializes boolean, datetime, and JSON values before persistence, and hydrates typed entity properties on reads. Creating another manager over the same SQLite connection does not recreate or truncate existing tables.

Schema constraints

#[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.

Why not Doctrine?

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.

Phase 2 status (framework-side)

  • ✅ #[AsCommand] CLI command auto-discovery.
  • ✅ JS-module runtime helper — framework csrf(), fetchJson(), per-feature client.js serving.
  • ✅ Middleware pipeline — #[Middleware] attribute + auto-discovery
    • Kernel runtime invocation + tests. Additionally: onion-style runtime pipeline via Kernel::pipe(MiddlewareInterface) — pipes wrap the whole lifecycle (events → CSRF → discovered middleware → hooks → controller) and may transform the outgoing response (MiddlewareDispatcher); top-level error boundary honours HttpKernelInterface $catch semantics and converts any escaping Throwable into a safe content-negotiated response via ErrorResponseFormatter (NQPHP_DEBUG=1 adds debug details).
  • ✅ Event system integrated with the Kernel — KernelRequestEvent (kernel.request) dispatched before CSRF / middleware / routing (listener setResponse() short-circuits the pipeline) and KernelResponseEvent (kernel.response) around every user-facing response; #[EventListener] attribute + EventListenerDiscoverer auto-subscribe listeners at boot (framework /_nqphp/... namespace excluded).
  • ✅ #[Schedule] discovery and CLI execution — schedule:list previews next runs; schedule:run evaluates Cron\CronExpression::isDue() and invokes due callables. The current runner does not use Symfony Scheduler.
  • ✅ bin/console schedule:list + schedule:run — proper introspection table (NAME / CRON / NEXT RUN / DESCRIPTION) with --raw / --timezone flags, plus a runner that evaluates Cron\CronExpression::isDue() and invokes due callables, with --force / --dry-run / --now / --lock flags and exit codes distinguishing invalid-cron (2) from runtime errors (1).
  • ✅ Per-feature config.php autoloader — 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], EntityManager with findBy / findOneBy / count / persist, plus DriverInterface with InMemoryDriver (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 to name for 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 SqliteDriver as default with NQPHP_DRIVER=memory env override falling back to InMemoryDriver.
  • ✅ EntityManager::remove() (Phase 2 #11) — idempotent delete by #[Id], throws on never-persisted entities. Mirrors persist() symmetry; cascade is opt-in via explicit calls (callers control what gets removed when a parent goes away).
  • ✅ View Engine & Scoped CSS — нативный движок представлений ViewRenderer, TemplateEngine, компиляция скоупированного CSS через ScopedCss (современный @scope) и ScopedCssCompiler (дедупликация и изоляция селекторов), RouteAssetManager (связка с manifest.json и заголовками X-NQ-CSS/X-NQ-JS для динамического свапа фрагментов в nq.js).
  • ✅ Database Migrations — движок миграций Migrator, репозиторий истории MigrationRepository в транзакциях, базовый класс AbstractMigration, CLI-команды make:migration, migrate, migrate:rollback.
  • ✅ Background Queue & Worker — очередь на БД DatabaseQueue, воркер Worker, интерфейс задач JobInterface с ретраями, CLI-команда queue:work.
  • ✅ Vertical Slice Generators — CLI-скаффолдеры архитектуры фич: make:feature, make:controller, make:entity.
  • ✅ Caching & Assets CLI — cache:clear (очистка сторов), assets:build (сборка scoped стилей/скриптов с генерацией манифеста), system:health (проверка окружения и здоровья системы).

Open Phase 2 follow-ups (not yet shipped, lower priority):

  • Full Symfony DI integration (per-feature services.yaml with actual service definitions; current per-feature config.php is 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).

License

MIT. See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages