Skip to content

AlfaCode-Team/php-service-platform

AlfacodeTeam PhpServicePlatform

A modular PHP 8.4+ service framework built on the Gated Demand Architecture (GDA). Security runs before any module loads, and only the modules a request actually needs are ever wired in. The kernel is codenamed Sentinel.

PHP License Runtime

It ships as a native cross-platform CLI (hkm) built with Zig, so you install and upgrade it like a Go/Rust binary — no Composer needed to get started.


Table of contents

  1. Why GDA?
  2. Install
  3. The hkm CLI
  4. Your first project
  5. Core concepts
  6. The request lifecycle
  7. Building a feature — end to end
  8. The five access rules
  9. Batteries included (plugins)
  10. Development from source
  11. Security defaults
  12. License

Why GDA?

Most frameworks boot everything, then decide what to do. GDA inverts that:

Principle What it means in practice
Security before everything A SecurityGateway runs before any module loads. A denied request costs zero module wiring.
Load only what is needed Only the modules required for this route are registered — resolved from a dependency graph per request.
One module, one domain Every module owns exactly one bounded business domain. No exceptions.
Isolation by default Modules cannot touch each other's internals — request-scoped containers enforce this at runtime.
Infrastructure independence The kernel defines port interfaces; the project supplies implementations (MySQL, Redis, S3, …).
Explicit over implicit Everything is declared in module.json. Nothing is auto-discovered at runtime.

The result: predictable performance (you pay only for what a route uses), strong domain boundaries that hold at runtime, and infrastructure you can swap without touching business code.

This is not Laravel, Symfony, or Slim. It borrows none of their conventions. If you're coming from those, unlearn the globals and facades — everything here is explicit and injected.

The three worlds

┌─────────────────────────────────────────────────┐
│  PROJECT LAYER   (wiring only — no business logic)│
│  ┌─────────────────────────────────────────────┐ │
│  │  MODULE / PLUGIN LAYER  (bounded domains)   │ │
│  │  ┌───────────────────────────────────────┐  │ │
│  │  │  KERNEL (Sentinel)                    │  │ │
│  │  │  boot · security · loading · DI ·     │  │ │
│  │  │  pipelines · events · ports           │  │ │
│  │  └───────────────────────────────────────┘  │ │
│  └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
  • Kernel — knows nothing about your domains. Changes rarely.
  • Module/Plugin — knows nothing about the project. Wires to the kernel through contracts.
  • Project — knows everything, contains no business logic. Pure wiring.

Install

Download the latest build from Releases.

Linux (Debian / Ubuntu / Kali)

sudo apt install ./hkm-kernel_<version>_amd64.deb
hkm doctor        # verify PHP + extensions

macOS — extract hkm-kernel-<version>-macos-universal.tar.gz, then run HKM.app/Contents/Resources/opt/hkm-kernel/install.sh.

Windows — extract hkm-kernel-<version>-windows-x86_64.zip, run hkm-kernel\install.bat, and add the folder to PATH.

The launcher self-locates the kernel — no environment variables required on a standard install. Runtime PHP dependencies are resolved with Composer on the target at install time, so they match your exact PHP.

Requirements (verified by hkm doctor)

  • PHP ≥ 8.4.1
  • Extensions: json, mbstring, ctype, tokenizer, filter, pdo, openssl, curl, fileinfo
  • At least one PDO driver: mysql · pgsql · sqlite · sqlsrv
  • Optional: redis, swoole/openswoole, gd, intl

The hkm CLI

Command Purpose
hkm new <path> [--project=<name>] Scaffold a new project (secure defaults: .env chmod 600, Apache + nginx configs)
hkm run [path|name] Run a project locally (PHP dev server)
hkm cli [command] Run a project's console interactively
hkm worker [args] Run a project's queue worker
hkm list List registered projects
hkm plugins [path|name] Analyse a project's enabled plugins/modules
hkm ui [sync|list|link|clean] Federate enabled plugins' UIs into the frontend
hkm doctor Diagnose PHP, extensions, and the resolved kernel path
hkm-config Set up / repair the full environment (kernel + userdata)
hkm upgrade [--check] Check for and install a newer release
hkm version / --version / -v Show the Sentinel banner + version
hkm <command> --dev Run any command against the development kernel checkout

Environment (all auto-detected — override only for non-standard layouts)

Variable Meaning
HKM_KERNEL_HOME Kernel root (composer.json, vendor/, projects/, templates/)
HKM_DEV_HOME Development kernel checkout used by --dev (hkm-config set-dev-home <path>)
HKM_USERDATA_DIR Persistent registry (projects.json + platform.json) that survives updates
HKM_PHP_BIN Override the php binary
HKM_CLI_PATH / HKM_GLOBAL_AUTOLOAD Override the PHP CLI script / kernel autoload

Run hkm-config once and it pins HKM_KERNEL_HOME and provisions a persistent HKM_USERDATA_DIR (migrating any existing registry) into ~/.config/hkm/config.env.


Your first project

# 1. Scaffold — creates a hardened project skeleton
hkm new ~/apps/shop --project=shop

# 2. Verify the environment
hkm doctor

# 3. Run it locally
hkm run shop
#   → serving http://127.0.0.1:8000  (docroot pinned to app/public)

# 4. Console + queue worker for the same project
hkm cli shop migrate            # run migrations (LetMigrate)
hkm worker shop                 # drain the job queue

A project is wiring only. Its bootstrap composes the kernel from a shared base and declares which plugins it activates:

// projects/shop/bootstrap/app.php
/** @var Kernel $builder */
$builder = require __DIR__ . '/../../../app/bootstrap/base.php';

return $builder
    ->withProjectPath(dirname(__DIR__))
    ->withModules([
        Plugins\Auth\Provider::class,
        Plugins\User\Provider::class,
        Plugins\Task\Provider::class,
    ])
    ->build();

Incoming requests are mapped to a project by host (app.example.com → the shop project) via DomainResolver, falling back to the HKM_PROJECT env var, then admin.


Core concepts

Modules & plugins

A plugin (local business module) lives under plugins/<Name>/, uses the Plugins\<Name>\ namespace, and follows a strict GDA folder layout:

plugins/Invoice/
├── module.json                              ← single source of truth
├── API/Contracts/InvoiceServiceContract.php ← the ONLY thing other modules may import
├── Domain/                                  ← entities, value objects, domain events (zero external imports)
├── Application/Services/InvoiceService.php  ← transaction + event orchestration
├── Infrastructure/
│   ├── Persistence/InvoiceRepository.php    ← DatabasePort only
│   ├── Gateways/StripeGateway.php           ← vendor SDK only
│   └── Http/Controllers/InvoiceController.php ← ≤3-line actions
└── Provider.php                             ← implements ModuleContract

module.json — the single source of truth

Routes, config, dependencies, and emitted events are declared, never discovered:

{
  "name": "invoice",
  "solves": "invoice.generation",
  "type": "module",
  "requires": ["database.query"],
  "exposes": ["InvoiceServiceContract"],
  "routes": [
    { "method": "GET",  "path": "/api/invoices",      "handler": "InvoiceController@index" },
    { "method": "POST", "path": "/api/invoices",      "handler": "InvoiceController@create", "filters": ["auth", "throttle:60,1"] },
    { "method": "GET",  "path": "/api/invoices/{id}", "handler": "InvoiceController@show" }
  ],
  "emits": ["invoice.created", "invoice.paid"],
  "config": ["INVOICE_CURRENCY", { "key": "INVOICE_TAX_RATE", "type": "float", "required": false }]
}

If a module reads an env var that isn't in config[], boot fails — no silent misconfig.

Ports (infrastructure independence)

The kernel defines interfaces; the project binds implementations once, at the app-lifetime container:

->withPorts([
    DatabasePort::class => new MySQLAdapter(config('database')),
    CachePort::class    => new RedisAdapter(config('cache')),
    QueuePort::class    => new RedisQueueAdapter(config('jobs')),
    MailPort::class     => new SmtpMailAdapter(config('mail')),
    StoragePort::class  => new S3StorageAdapter(config('storage')),
])

Swap MySQL for Postgres, or SMTP for SES, without touching a single line of domain code.


The request lifecycle

Request
  │
  ▼  SecurityGateway  (runs BEFORE any module loads)
  │   Firewall → RateLimiter → CSRF → [your Auth layer]   ── deny = zero module cost
  ▼
HTTP pipeline
  1. CorrelationIdStage   propagate X-Correlation-ID
  2. SecurityStage        run the gateway, attach Identity
  3. ResolveStage         route-manifest lookup → service name
  4. LoadStage            build dependency graph → wire ONLY those modules
     ↳ RouteFilterStage   run the route's declared filters[] (auth, throttle, …)
  5. ExecuteStage         contract → DTO → controller → Response
  6. ErrorStage (wraps all) classify → notify (Slack/Mail/DB/File) → HTTP response

Every stage is handle(Request $request, callable $next): Response. Modules add cross-cutting behaviour by registering hooks in Provider::boot(), or opt individual routes into named filters via module.json.


Building a feature — end to end

A complete vertical slice. Each layer has one job and may only talk to the layer below it.

1. Domain — pure, zero external imports

// Domain/ValueObjects/Money.php
final readonly class Money
{
    private function __construct(private int $amount, private string $currency) {   // integer cents — NEVER float
        if ($this->amount < 0) throw new \DomainException('Money cannot be negative');
    }
    public static function of(int|float $amount, string $currency): self {
        return new self((int) round($amount * 100), strtoupper($currency));
    }
    public function add(self $o): self {
        if ($this->currency !== $o->currency) throw new \DomainException('Currency mismatch');
        return new self($this->amount + $o->amount, $this->currency);   // operations return NEW instances
    }
    public function amount(): int { return $this->amount; }
}

2. Service — transaction + event orchestration (the mandatory shape)

final class InvoiceService implements InvoiceServiceContract
{
    public function __construct(
        private readonly InvoiceRepository    $repository,
        private readonly TransactionManager   $transaction,
        private readonly DomainEventCollector $collector,
        private readonly EventBus             $eventBus,
        private readonly Identity             $identity,     // from the SecurityGateway
    ) {}

    public function create(CreateInvoiceDTO $dto): InvoiceResponseDTO
    {
        // authorization first
        if (!$this->identity->hasPermission('invoice:create')) {
            throw new ServiceException('invoice.unauthorized', layer: 'service.invoice');
        }

        $this->collector->beginCollection();
        $this->transaction->begin();
        try {
            $invoice = Invoice::create(ClientId::from($dto->clientId), Money::of($dto->amount, 'USD'));
            foreach ($invoice->releaseEvents() as $e) $this->collector->collect($e);
            $this->repository->save($invoice);
            $this->transaction->commit();
        } catch (\Throwable $e) {
            $this->transaction->rollback();
            $this->collector->discard();                     // ALWAYS — no phantom events on rollback
            throw new ServiceException('invoice.create.failed', layer: 'service.invoice', previous: $e);
        }

        // integration events dispatch ONLY after a successful commit — never inside try{}
        $this->eventBus->dispatch(new InvoiceCreatedIntegrationEvent($invoice->id()->value(), $dto->amount));

        return InvoiceResponseDTO::from($invoice);
    }
}

3. Repository — DatabasePort only, translate every \PDOException

final class InvoiceRepository
{
    public function __construct(private readonly DatabasePort $db, private readonly Identity $identity) {}

    public function find(string $id): Invoice
    {
        try {
            $row = $this->db->queryOne(
                'SELECT * FROM invoices WHERE id = :id AND tenant_id = :t AND deleted_at IS NULL',
                ['id' => $id, 't' => $this->identity->tenantId],     // ALWAYS tenant-scoped
            );
        } catch (\PDOException $e) {
            throw new RepositoryException("find invoice [$id]", layer: 'repository.invoice', previous: $e);
        }
        return $row ? InvoiceHydrator::hydrate($row) : throw new RepositoryException("Invoice [$id] not found");
    }
}

4. Controller — ≤3 lines: DTO → service → Response

final class InvoiceController
{
    public function __construct(private readonly InvoiceServiceContract $service) {}   // contract only

    public function create(Request $request): Response
    {
        $dto = CreateInvoiceDTO::fromRequest($request);   // validation happens here
        return Response::json($this->service->create($dto)->toArray(), 201);
    }
}

5. Provider — wire it together

class Provider implements ModuleContract
{
    public function solves(): string  { return 'invoice.generation'; }
    public function requires(): array { return [DatabasePort::class]; }
    public function exposes(): array  { return [InvoiceServiceContract::class]; }

    public function register(ModuleContainer $c): void
    {
        $c->bindInternal(InvoiceRepository::class, fn($c) =>
            new InvoiceRepository($c->make(DatabasePort::class), $c->make(Identity::class)));

        $c->bind(InvoiceServiceContract::class, fn($c) => new InvoiceService(
            $c->make(InvoiceRepository::class), $c->make(TransactionManager::class),
            $c->make(DomainEventCollector::class), $c->make(EventBus::class), $c->make(Identity::class),
        ));
    }

    public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void
    {
        // $events->subscribe('payment.succeeded', MarkInvoicePaidListener::class);
    }
}

Add Plugins\Invoice\Provider::class to a project's withModules([...]) and the routes, config validation, and DI are live.

Testing — always fakes, never real infrastructure

$sut = new InvoiceService(
    new InMemoryInvoiceRepository(), new FakeTransactionManager(),
    new DomainEventCollector(), $bus = new FakeIntegrationEventBus(), Identity::asUser('u-1', 'tenant-a'),
);
$result = $sut->create($validDto);

$bus->assertDispatched(InvoiceCreatedIntegrationEvent::class, times: 1);
$this->assertNotNull($result->invoiceId);

The five access rules

These are enforced at runtime by ModuleContainer::bindInternal() — a violation throws ScopeViolationException, not a lint warning.

Controller  →  Service      (published contract interface ONLY)
Service     →  Repository AND Gateway   (the ONLY layer that may call both)
Repository  →  DatabasePort ONLY        (no HTTP, no vendor SDK)
Gateway     →  Vendor SDK ONLY          (no DB, no services)
Domain      →  NOTHING EXTERNAL         (zero imports outside Domain/)

Never (a partial list the framework actively rejects):

  • Routes defined in PHP — only in module.json / proj.json.
  • float for money — use a Money value object with integer cents.
  • Vendor exceptions (\PDOException, Stripe, …) escaping their layer — translate them.
  • Integration events dispatched inside a try{} — only after commit.
  • Another module's internal class imported — use its published contract.
  • getenv() for a .env value — use the env() helper.
  • Business logic in a controller — max 3 lines.

Batteries included (plugins)

Drop-in modules under plugins/, activated per project:

Plugin Domain What you get
Auth auth.identity JWT / PAT / session issuance + verification, refresh-token rotation, guards
OAuth2 oauth.server Native OAuth 2.1 + OIDC server (auth code + PKCE, device code, JWKS, introspection)
User user.management Central identity store, email verification, transactional outbox, audit log
Tenancy tenancy.routing Multi-tenant DB routing, memberships, invitations, per-tenant isolation
Validation validation.rules Request validation engine + AbstractDto (rules()), ~45 built-in rules
Mail mail.delivery Native dependency-free mailer — SMTP/Sendmail/mail(), DKIM, attachments
Storage storage.local StoragePort over local disk or S3 (Flysystem), signed temp URLs
Session / Cookie session.management / http.cookies Encrypted sessions, flash, CSRF; queued encrypted cookies
HttpClient http.client HttpClientPort (cURL) with idempotent-safe retries + coroutine backoff
View / ViteManifest / Pageflow frontend PHP templating, Vite asset resolution, Inertia-style SPA bridge
SecurityFilters http.security_filters CORS + secure headers; route-filter aliases auth, throttle, hmac, shield
I18n i18n.translation File-based translator, pluralization, Accept-Language negotiation

Each plugin ships its own README.md — e.g. Auth, Tenancy, User, OAuth2.


Development from source

git clone --recurse-submodules git@github.com:AlfaCode-Team/php-service-platform.git
cd php-service-platform
composer install                           # also wires the git hooks (core.hooksPath=.githooks)
vendor/bin/phpunit                         # run the test suite

# If you skip composer, enable the repo git hooks manually (strips AI co-author
# trailers from commit messages):
git config core.hooksPath .githooks

# Build the native launcher (needs Zig — see tools/.zig-version):
cd tools && zig build --release=small      # → ../bin/hkm + ../bin/hkm-config

Building release bundles

VERSION=1.2.3 ./tools/bundle.sh all         # .deb + macOS .app + Windows .zip → dist/
# MODULES=git ./tools/bundle.sh linux       # fetch path-repo modules from pinned commits

Releases are cut by pushing a v* tag — CI runs the test suite first, then builds all three OS bundles and publishes them automatically.

Releasing (branch model + automation)

Development happens on the master dev branch; main is the stable release branch. Releases are CHANGELOG-driven and automatic — you never tag by hand.

  1. Do your work on master and commit.
  2. Add a new ## [x.y.z] - YYYY-MM-DD section to CHANGELOG.md (below ## [Unreleased]), describing the changes.
  3. Open a PR mastermain and merge it.
  4. On merge, the Auto Release workflow (.github/workflows/auto-release.yml) reads the top CHANGELOG version and, if no vX.Y.Z tag exists yet, creates the tag and calls the Release workflow — which runs the test gate, builds all OS bundles, and publishes the GitHub Release (notes pulled from that CHANGELOG section).

Notes:

  • A release only starts when the merge changes CHANGELOG.md and introduces a version not already tagged — ordinary merges don't publish anything.
  • No secret/PAT is required: Auto Release invokes the Release workflow directly via workflow_call.
  • You can still cut a release manually at any time by pushing a v* tag.

For deep dives, see the layer guides in docs/ai-context/ and the CHANGELOG.


Security defaults

Scaffolded projects are hardened out of the box:

  • .env is chmod 600; debug output is force-disabled when APP_ENV=production.
  • Every project ships web-server configs (app/public/.htaccess, app/apache.conf.example, app/nginx.conf.example) pinning the docroot to app/public, denying dotfiles, and adding baseline security headers.
  • The SecurityGateway (firewall → rate limiter → CSRF → your auth layer) runs before any module — denied requests never touch business code.
  • Keep secrets (APP_KEY, JWT signing keys, DB credentials) out of the CLI config and, in production, behind a SECRETS_PROVIDER.

License

MIT — see LICENSE.

About

A modular PHP backend boilerplate built with OpenSwoole and a multi-repository architecture using Git submodules. Designed for scalable, high-performance microservices and service-oriented applications.

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors