Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

48 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] attribute + Symfony Scheduler wiring
  • 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)

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, symfony/scheduler. Not framework-bundle — too opinionated. We glue components together.

  • 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)

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

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.

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.
  • ✅ #[Schedule] cron + Symfony Scheduler integration — discover + list + run via the real Symfony Scheduler evaluator.
  • ✅ 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).

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

Deployment

The site is deployed to the VPS (<твоя_впс>) on every push to main via GitHub Actions (.github/workflows/deploy.yml) using rsync over SSH.

Required GitHub Secrets

Configure these repository secrets under Settings → Secrets and variables → Actions:

Secret Description
VPS_HOST <твоя_впс> (the VPS IP)
VPS_USER SSH user on the VPS
VPS_SSH_KEY Private SSH key with access to the target path
VPS_PATH Absolute remote path where the site lives (e.g. /var/www/nqphp-site)

License

MIT. See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages