DQL-centric ORM for PHP 8.2+ with MVC/ADR dual-pattern, HAL+JSON API, and zero-config SQLite demo.
- Quick Start (SQLite, 30 seconds)
- Console CLI Commands
- Running the Application
- Architecture Overview
- MVC Pattern (Oryx\Mvc)
- ADR Pattern (PSR-7 responders)
- HAL+JSON API
- Fractal Transformers
- Command/Handler Pattern (Tactician)
- Fixtures
- Middleware Security
- Logging (Monolog)
- PWA Support
- Environment Configuration
- Doctrine Regional Cache
- XML Schema-Driven Entity Generation
- Role-Based Access su Doctrine Collections
- Group STI ir UserGroup
- Persistent Storage
- Flysystem File Cache
- Domain Event Bus
- Summary
No database server needed. SQLite is the default driver.
# 1. Install dependencies
composer install
# 2. Copy environment configuration (verbose mode)
cp -v .env.dist .env
# 3. Copy assets from Oryx MVC vendor package
cp -v ./vendor/oryx/mvc/public/favicon.ico ./public/favicon.ico
cp -v ./vendor/oryx/mvc/public/manifest.json ./public/manifest.json
cp -v ./vendor/oryx/mvc/public/sw.js ./public/sw.js
cp -rv ./vendor/oryx/mvc/public/icons/ ./public/icons/
# 4. Create required directories
mkdir -p var/log var/data
# 5. Create database and run migrations
bin/console oryx:db:setup
# 5. Load demo fixtures (groups, roles, users)
bin/console oryx:fixtures:load
# 6. Start the server
composer serve
# 7. (Optional) Start async event worker for background processing
# For development: php bin/async-event-worker.php &
# For production: use supervisor or systemd to manage the workerOpen http://localhost:8080 — you should see the home page with users and API links.
The APP_ENV variable in .env controls both debug output and proxy generation:
| Mode | Debug Output | Proxy Generation |
|---|---|---|
dev (default) |
Full error details | In-memory (eval) — no files needed |
prod |
Generic "An error occurred" | Never auto-generates — run bin/console orm:proxy:generate before deploying |
To switch modes, simply edit .env:
# Development (default)
APP_ENV=dev
# Production
APP_ENV=prodNo separate debug toggle — one variable, two behaviors.
| URL | What |
|---|---|
| http://localhost:8080/ | MVC home page (PHP templates) |
| http://localhost:8080/users | User list with CRUD |
| http://localhost:8080/api/users | HAL+JSON API collection |
| http://localhost:8080/api/users/1 | Single user resource |
| http://localhost:8080/api/users?include=userRoles | With role system |
Domain Events: Entities automatically emit EntityCreated, EntityUpdated, and EntityDeleted events on create/update/delete. Use $emitter->emit($event) for synchronous handling or $emitter->emitAsync($event) for background processing via the async worker.
Edit .env.yaml:
database:
driver: pdo_mysql
host: localhost
port: 3306
name: orm_db
user: root
password: secretThen recreate: bin/console oryx:db:create --force && bin/console oryx:fixtures:load
- PHP 8.2+
- Extensions: mbstring, intl, pdo_sqlite (included), pdo_mysql (optional)
See REQUIREMENTS.md for complete system requirements and dependencies.
| Command | Description |
|---|---|
bin/console list |
List all commands |
bin/console oryx:db:create |
Create SQLite/MySQL database and schema |
bin/console oryx:db:setup |
Create database + apply migrations (compound command) |
bin/console oryx:fixtures:load |
Load demo fixtures using Faker |
bin/console orm:generate:entities |
Generate entity classes from XML schema |
# Create SQLite database (default)
bin/console oryx:db:create
# Force recreate (drops existing)
bin/console oryx:db:create --forceThe oryx:db:setup command combines database creation and migration application in one step:
# Create database and apply all migrations
bin/console oryx:db:setup
# Force recreate (drops existing database first)
bin/console oryx:db:setup --force
# Show SQL without executing (dry-run)
bin/console oryx:db:setup --dry-run| Option | Description |
|---|---|
--force |
Drop existing database if it exists (SQLite only) |
--dry-run |
Show SQL that would be executed without running it |
# Default: 3 groups, 10 users
bin/console oryx:fixtures:load
# Custom user count
bin/console oryx:fixtures:load --users=50
# Purge existing data first
bin/console oryx:fixtures:load --purge
# Reproducible random data
bin/console oryx:fixtures:load --seed=42| Option | Default | Description |
|---|---|---|
--users |
10 | Number of users |
--purge |
— | Purge data before loading |
--seed |
null | Random seed |
# Generate all entities
bin/console orm:generate:entities
# Generate specific entity
bin/console orm:generate:entities --filter=User
bin/console orm:generate:entities --filter='App\Entity\DeveloperGroup'vendor/bin/doctrine-migrations diff --configuration=migrations.yaml
vendor/bin/doctrine-migrations migrate --configuration=migrations.yaml
vendor/bin/doctrine-migrations status --configuration=migrations.yamlphp -S localhost:8080 -t public
# or
composer serve| URL | Pattern | Entry |
|---|---|---|
| http://localhost:8080/ | MVC | PHP Templates |
| http://localhost:8080/users | MVC | PHP Templates |
| http://localhost:8080/api/users | ADR | HAL+JSON |
| http://localhost:8080/api/users/1 | ADR | HAL+JSON |
| http://localhost:8080/manifest.json | PWA | JSON Manifest |
composer test
vendor/bin/phpunit --testsuite Action
vendor/bin/phpunit --coverage-textFor complete API testing documentation including all endpoint references, curl examples, Postman collection, and validation specimens, see TESTER.md.
┌─────────────────────────────────────────────────────────────────┐
│ ORYX WEB SKELETON │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Entry Points │ │
│ │ public/index.php → Routes MVC ↔ ADR │ │
│ │ public/mvc.php → MVC only │ │
│ │ public/api.php → ADR only │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────┴──────────────────┐ │
│ ▼ ▼ │
│ ┌─────────────────────┐ ┌─────────────────────────┐ │
│ │ MVC Layer │ │ ADR Layer │ │
│ │ (Oryx\Mvc) │ │ (PSR-7 responders) │ │
│ ├─────────────────────┤ ├─────────────────────────┤ │
│ │ • App\Http\Request │ │ • App\Kernel │ │
│ │ • App\Http\Response │ │ • App\Action\User\* │ │
│ │ • App\Routing\MvcRoutes │ │ • League\Fractal │ │
│ │ • Oryx\Mvc\Application │ │ • JsonHalResponder │ │
│ │ • PHP Templates │ │ │ │
│ └─────────────────────┘ └─────────────────────────┘ │
│ │ │
│ ┌────────────────────────────┴───────────────────────────┐ │
│ │ Command/Handler Pattern │ │
│ │ • League\Tactician (CommandBus) │ │
│ │ • 16 Commands + 16 Handlers (User/Group/Console) │ │
│ │ • PHP-DI autowiring for dependency injection │ │
│ └───────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────────┴───────────────────────────┐ │
│ │ Logging (Monolog) │ │
│ │ • AppLogger (dev: Debug, prod: Error) │ │
│ │ • CrashLogger (critical errors → crash.log) │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
MVC uses Oryx\Mvc components for core HTTP handling and routing.
// src/Http/Request.php
namespace App\Http;
use Oryx\Mvc\Http\Request as BaseRequest;
class Request extends BaseRequest {}// src/Http/Response.php
namespace App\Http;
use Oryx\Mvc\Http\Response as BaseResponse;
class Response extends BaseResponse {}// src/Routing/MvcRoutes.php
namespace App\Routing;
use App\Http\Request;
use App\Http\Response;
use Oryx\Mvc\Application as MvcApplicationRouter;
use App\View\ViewRenderer;
// ... (other use statements)
class MvcRoutes
{
private MvcApplicationRouter $router;
// ...
public function __construct(..., ViewRenderer $view)
{
$this->router = new MvcApplicationRouter($view); // Oryx\Mvc\Application is the router
// ...
}
// ...
private function register(): void
{
$this->router->get('/', function (Request $req) { // App\Http\Request
return new Response($this->view->renderWithLayout('home', [...])); // App\Http\Response
});
// ...
}
}// src/Controller/UserController.php
namespace App\Controller;
use App\Entity\User;
use App\Repository\UserRepository;
use Oryx\Mvc\AbstractController;
use Doctrine\ORM\EntityManagerInterface;
class UserController extends AbstractController
{
private EntityManagerInterface $em;
private UserRepository $repository;
public function __construct(EntityManagerInterface $em)
{
parent::__construct(); // Call parent constructor if needed
$this->em = $em;
$this->repository = new UserRepository($em);
}
public function index(): array
{
$users = $this->repository->findAll();
return ['users' => $users];
}
public function show(int $id): ?User
{
return $this->repository->find($id);
}
public function create(array $data): User
{
$user = new User();
$user->setEmail($data['email']);
$user->setPassword(password_hash($data['password'] ?? '', PASSWORD_BCRYPT));
$user->setRoles($data['roles'] ?? ['ROLE_USER']);
$this->em->persist($user);
$this->em->flush();
return $user;
}
public function delete(int $id): bool
{
$user = $this->repository->find($id);
if (!$user) {
return false;
}
$this->em->remove($user);
$this->em->flush();
return true;
}
}// src/App/MvcApplication.php
namespace App;
use App\Http\Request; // Still uses App\Http\Request
use App\Http\Response; // Still uses App\Http\Response
use App\Routing\MvcRoutes;
use App\View\ViewRenderer;
use Oryx\ORM\EntityManager;
// ... (other use statements)
class MvcApplication
{
private MvcRoutes $mvcRoutes;
// ...
public function __construct(EntityManager $em)
{
// ...
$this->mvcRoutes = new MvcRoutes(
$em,
// ... all dependencies
);
}
public function run(): void
{
$request = new Request($_SERVER); // App\Http\Request
$response = $this->mvcRoutes->getRouter()->dispatch($request); // Dispatches to MvcRoutes
$response->send();
}
}| Method | Path | Description |
|---|---|---|
GET |
/ |
Home page |
GET |
/users |
User list |
GET |
/users/create |
Create form |
POST |
/users/create |
Create user |
GET |
/users/{id} |
Show user |
ADR naudoja PSR-7/PSR-15, o JsonHalResponder remiasi Oryx\\Adr\\Responder\\JsonApiResponder kaip bazine JSON atsako implementacija.
API action klasės (User/*Action, Group/*Action) paveldi App\Action\AbstractAdrAction, kuris implementuoja Oryx\Adr\Action\ActionInterface. Taip API sluoksnis laikosi vendor ADR kontrakto be vendor/ modifikacijų.
Maršrutai atskirti nuo Kernelio į App\Routing\*Routes klases - lengviau tvarkyti ir testuoti.
/api/* užklausos dispatchinamos per AdrRoutes be privalomo MvcRoutes inicializavimo, todėl API boot nebepriklauso nuo MVC view sluoksnio.
// src/Kernel.php
namespace App;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Log\LoggerInterface;
use Laminas\ServiceManager\ServiceManager;
use DI\ContainerBuilder;
use DI\Container;
use Symfony\Component\Dotenv\Dotenv;
use Oryx\ORM\EntityManager;
use App\View\ViewRenderer;
use App\Routing\AdrRoutes;
use App\Middleware\SecurityMiddleware;
use App\Middleware\CorsMiddleware;
use App\Middleware\RateLimitMiddleware;
use App\Middleware\CsrfMiddleware;
use App\Middleware\JsonErrorHandler;
use App\Logger\CrashLogger;
use App\Logger\LoggerFactory;
use League\Fractal\Manager as FractalManager;
use League\Fractal\Serializer\JsonApiSerializer;
use App\Command\User\CreateUserCommand;
use App\Command\User\UpdateUserCommand;
use App\Command\User\PatchUserCommand;
use App\Command\User\DeleteUserCommand;
use App\Command\User\GetUserCommand;
use App\Command\User\ListUsersCommand;
use App\Command\Group\CreateGroupCommand;
use App\Command\Group\UpdateGroupCommand;
use App\Command\Group\PatchGroupCommand;
use App\Command\Group\DeleteGroupCommand;
use App\Command\Group\GetGroupCommand;
use App\Command\Group\ListGroupsCommand;
use App\Command\Console\LoadFixturesCommand;
use App\Command\Console\CreateDatabaseCommand;
use App\Command\Console\GenerateProxiesCommand;
use App\Command\Console\ManageUserCommand;
use App\Handler\User\CreateUserHandler;
use App\Handler\User\UpdateUserHandler;
use App\Handler\User\PatchUserHandler;
use App\Handler\User\DeleteUserHandler;
use App\Handler\User\GetUserHandler;
use App\Handler\User\ListUsersHandler;
use App\Handler\Group\CreateGroupHandler;
use App\Handler\Group\UpdateGroupHandler;
use App\Handler\Group\PatchGroupHandler;
use App\Handler\Group\DeleteGroupHandler;
use App\Handler\Group\GetGroupHandler;
use App\Handler\Group\ListGroupsHandler;
use App\Handler\Console\LoadFixturesHandler;
use App\Handler\Console\CreateDatabaseHandler;
use App\Handler\Console\GenerateProxiesHandler;
use App\Handler\Console\ManageUserHandler;
use App\Command\CommandBusInterface;
use App\Command\TacticianCommandBus;
use League\Tactician\CommandBus;
use League\Tactician\Handler\CommandHandlerMiddleware;
use League\Tactician\Handler\Mapping\MapByStaticList;
use Oryx\ORM\EntityManagerFactory;
use App\Fixture\FixtureLoader;
use App\Dto\DtoFactory;
use App\Routing\MvcRoutes;
use App\Container\LaminasServiceManagerFactory;
use function DI\autowire;
/**
* ADR API Kernel - routes separated to App\Routing\AdrRoutes.
*
* Middleware Stack (in order):
* 1. SecurityMiddleware - Headers
* 2. CorsMiddleware - CORS
* 3. RateLimitMiddleware - Rate limiting
* 4. CsrfMiddleware - CSRF protection
*/
class Kernel
{
private string $environment;
private Container $container;
private AdrRoutes $adrRoutes;
private ?MvcRoutes $mvcRoutes = null;
private EntityManager $entityManager;
private FractalManager $fractal;
private ?CrashLogger $crashLogger = null;
private ?LoggerInterface $logger = null;
public function __construct(string $environment = 'dev')
{
$this->environment = $environment;
$builder = new ContainerBuilder();
$builder->useAutowiring(true);
$builder->useAttributes(true);
$this->container = $builder->build();
$this->boot();
}
public function boot(): void
{
$this->loadEnvironment();
$this->createServices();
$this->registerServices();
$this->initLogging();
$this->registerRoutes();
}
private function initLogging(): void
{
$this->logger = LoggerFactory::create($this->environment);
$this->crashLogger = new CrashLogger();
}
private function loadEnvironment(): void
{
$dotenv = new Dotenv();
$dotenv->bootEnv(dirname(__DIR__) . '/.env');
}
private function createServices(): void
{
$this->entityManager = EntityManagerFactory::getInstance();
$this->fractal = new FractalManager(null);
$this->fractal->setSerializer(new JsonApiSerializer());
}
private function registerServices(): void
{
$this->container->set(EntityManager::class, $this->entityManager);
$this->container->set(FractalManager::class, $this->fractal);
$this->container->set(FixtureLoader::class, autowire());
$this->container->set(DtoFactory::class, autowire());
$this->container->set(ViewRenderer::class, autowire());
$this->container->set(ServiceManager::class, LaminasServiceManagerFactory::create());
// WorkGroupMap service (injectable implementation)
$this->container->set(\App\Service\WorkGroupMapInterface::class, autowire(\App\Service\WorkGroupMap::class));
// Central FormProcessor service for consistent form validation
$this->container->set(\App\Service\FormProcessor::class, autowire());
$this->registerCommandBus();
}
private function registerCommandBus(): void
{
$commandToHandlerMap = [
CreateUserCommand::class => [CreateUserHandler::class, 'handle'],
UpdateUserCommand::class => [UpdateUserHandler::class, 'handle'],
PatchUserCommand::class => [PatchUserHandler::class, 'handle'],
DeleteUserCommand::class => [DeleteUserHandler::class, 'handle'],
GetUserCommand::class => [GetUserHandler::class, 'handle'],
ListUsersCommand::class => [ListUsersHandler::class, 'handle'],
CreateGroupCommand::class => [CreateGroupHandler::class, 'handle'],
UpdateGroupCommand::class => [UpdateGroupHandler::class, 'handle'],
PatchGroupCommand::class => [PatchGroupHandler::class, 'handle'],
DeleteGroupCommand::class => [DeleteGroupHandler::class, 'handle'],
GetGroupCommand::class => [GetGroupHandler::class, 'handle'],
ListGroupsCommand::class => [ListGroupsHandler::class, 'handle'],
LoadFixturesCommand::class => [LoadFixturesHandler::class, 'handle'],
CreateDatabaseCommand::class => [CreateDatabaseHandler::class, 'handle'],
GenerateProxiesCommand::class => [GenerateProxiesHandler::class, 'handle'],
ManageUserCommand::class => [ManageUserHandler::class, 'handle'],
];
foreach ($commandToHandlerMap as [$handlerClass]) {
$this->container->set($handlerClass, autowire());
}
$mapping = new MapByStaticList($commandToHandlerMap);
$commandHandlerMiddleware = new CommandHandlerMiddleware(
$this->container,
$mapping
);
$commandBus = new CommandBus($commandHandlerMiddleware);
$this->container->set(CommandBus::class, $commandBus);
$this->container->set(CommandBusInterface::class, autowire(TacticianCommandBus::class));
}
private function registerRoutes(): void
{
$this->adrRoutes = new AdrRoutes($this->container);
$router = $this->adrRoutes->getRouter();
$logger = $this->logger ?? LoggerFactory::create($this->environment);
$isDebug = $this->isDebug();
$router->middleware(new JsonErrorHandler($logger, $isDebug));
$router->middleware(new SecurityMiddleware($logger));
$router->middleware(new CorsMiddleware($logger));
$router->middleware(new RateLimitMiddleware($logger, 100, 60));
$router->middleware(new CsrfMiddleware($logger));
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$this->logger?->debug('Incoming request', [
'method' => $request->getMethod(),
'uri' => (string) $request->getUri(),
'ip' => $request->getServerParams()['REMOTE_ADDR'] ?? 'unknown',
'user_agent' => $request->getHeaderLine('User-Agent') ?: 'unknown',
]);
if (str_starts_with($request->getUri()->getPath(), '/api/')) {
return $this->adrRoutes->getRouter()->dispatch($request);
}
return $this->getMvcRoutes()->getRouter()->dispatch($request);
}
private function getMvcRoutes(): MvcRoutes
{
if ($this->mvcRoutes !== null) {
return $this->mvcRoutes;
}
$this->mvcRoutes = new MvcRoutes(
$this->entityManager,
$this->container->get(CommandBusInterface::class),
$this->container->get(DtoFactory::class),
$this->logger ?? LoggerFactory::create($this->environment),
$this->container->get(ViewRenderer::class),
$this->container->get(ServiceManager::class),
$this->container
);
return $this->mvcRoutes;
}
private function handleError(\Throwable $e): ResponseInterface
{
$isCrash = CrashLogger::isCrash($e);
$debug = $this->isDebug();
if ($isCrash) {
$this->crashLogger?->critical('CRASH: ' . $e->getMessage());
$status = 503;
$title = 'Service Unavailable';
$detail = 'A critical error occurred';
} else {
$this->logger?->error('Error: ' . $e->getMessage());
$status = 500;
$title = 'Internal Server Error';
$detail = $debug ? $e->getMessage() : 'An error occurred';
}
$extensions = $debug ? ['trace' => $e->getTraceAsString()] : [];
$responder = new \Oryx\Adr\Responder\ProblemDetailsResponder(
'/errors/internal-error',
$title,
$status,
$detail,
'/api',
$extensions
);
return $responder->respond();
}
public function terminate(): void
{
if ($this->entityManager->isOpen()) {
$this->entityManager->close();
}
}
public function getContainer(): Container
{
return $this->container;
}
public function getEntityManager(): EntityManager
{
return $this->entityManager;
}
public function getEnvironment(): string
{
return $this->environment;
}
public function isDebug(): bool
{
return !in_array($this->environment, ['prod', 'production'], true);
}
}// src/Action/User/ListAction.php
namespace App\Action\User;
use App\Action\AbstractAdrAction;
use App\Command\CommandBusInterface;
use App\Command\User\ListUsersCommand;
use App\Dto\DtoFactory;
use App\Repository\UserRepository;
use App\Responder\JsonHalResponder;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class ListAction extends AbstractAdrAction
{
private CommandBusInterface $commandBus;
private DtoFactory $dtoFactory;
private UserRepository $userRepository;
public function __construct(
CommandBusInterface $commandBus,
DtoFactory $dtoFactory,
UserRepository $userRepository
) {
$this->commandBus = $commandBus;
$this->dtoFactory = $dtoFactory;
$this->userRepository = $userRepository;
}
public function __invoke(ServerRequestInterface $request, ResponseInterface $response, ?callable $next = null): ResponseInterface
{
$limit = $request->getQueryParams()['limit'] ?? null;
$offset = $request->getQueryParams()['offset'] ?? null;
$search = $request->getQueryParams()['search'] ?? null;
$limit = $limit !== null ? (int) $limit : null;
$offset = $offset !== null ? (int) $offset : null;
$total = $search !== null && $search !== ''
? $this->userRepository->countAllWithFilter($search)
: $this->userRepository->countAll();
$command = new ListUsersCommand(
limit: $limit,
offset: $offset,
search: $search
);
$users = $this->commandBus->handle($command);
$dtos = array_map(
fn($user) => $this->dtoFactory->create($user, [
'userRoles' => $user->getUserRoles()->toArray(),
'workGroup' => $user->getWorkGroups()[0] ?? null,
'gamificationRoles' => $user->getGamificationRoles(),
]),
$users
);
return JsonHalResponder::collection('users', $dtos, [
'total' => $total,
'count' => count($dtos),
]);
}
}// src/Responder/JsonHalResponder.php
namespace App\Responder;
use Oryx\Adr\Responder\JsonApiResponder;
use Oryx\Adr\Responder\ProblemDetailsResponder;
use Psr\Http\Message\ResponseInterface;
class JsonHalResponder extends JsonApiResponder
{
public static function resource(string $type, string $id, mixed $attributes): ResponseInterface
{
$data = [
'_links' => ['self' => ['href' => "/{$type}/{$id}"]],
$type => array_merge(['id' => $id], is_object($attributes) ? get_object_vars($attributes) : $attributes),
];
return (new self($data, 200, ['Content-Type' => 'application/hal+json; charset=utf-8']))->respond();
}
public static function problem(string $type, string $title, int $status): ResponseInterface
{
return (new ProblemDetailsResponder($type, $title, $status))->respond();
}
}Full CRUD operations with HAL+JSON responses.
| Method | Endpoint | Action | Response | Description |
|---|---|---|---|---|
GET |
/api/users |
ListAction | 200 + HAL collection |
List all users |
GET |
/api/users/{id} |
ShowAction | 200 + HAL resource |
Get single user |
POST |
/api/users |
CreateAction | 201 + HAL resource |
Create user |
PUT |
/api/users/{id} |
UpdateAction | 200 + HAL resource |
Full update |
PATCH |
/api/users/{id} |
PatchAction | 200 + HAL resource |
Partial update |
DELETE |
/api/users/{id} |
DeleteAction | 204 No Content |
Delete user |
GET |
/api/groups |
ListAction | 200 + HAL collection |
List all groups |
GET |
/api/groups/{id} |
ShowAction | 200 + HAL resource |
Get single group |
POST |
/api/groups |
CreateAction | 201 + HAL resource |
Create group |
PUT |
/api/groups/{id} |
UpdateAction | 200 + HAL resource |
Full update |
PATCH |
/api/groups/{id} |
PatchAction | 200 + HAL resource |
Partial update |
DELETE |
/api/groups/{id} |
DeleteAction | 204 No Content |
Delete group |
Full API Testing Guide: See TESTER.MD for complete request/response specimens, Postman collection, and validation examples.
Kas yra HAL? Hypertext Application Language - standartas, kuris suteikia nuorodas (_links) ir įdėtinius resursus (_embedded). Skirtumas nuo JSON:API: paprastesnis, lengviau suprasti.
Kodėl DTO + JsonHalResponder? API action'ai grąžina DTO iš DtoFactory, o HAL formavimą centralizuoja JsonHalResponder, kuris paveldi Oryx\Adr\Responder\JsonApiResponder.
1. Vienas resursas (/api/users/1):
{
"_links": {
"self": { "href": "/api/users/1" }
},
"_embedded": {
"user": {
"id": 1,
"email": "admin@versliukai.lt",
"roles": ["ROLE_ADMIN"],
"created_at": "2024-01-15T10:30:00+02:00",
"updated_at": "2024-01-20T15:45:00+02:00"
}
}
}2. Kolekcija (/api/users):
{
"_links": {
"self": { "href": "/api/users" },
"next": { "href": "/api/users?page=2" }
},
"_embedded": {
"users": [
{ "id": 1, "email": "admin@versliukai.lt", "roles": ["ROLE_ADMIN"] },
{ "id": 2, "email": "user@versliukai.lt", "roles": ["ROLE_USER"] }
]
},
"_meta": {
"total": 150,
"count": 2,
"page": 1,
"per_page": 2
}
}3. Klaida (400/404/422/500):
{
"_error": {
"status": 422,
"title": "Unprocessable Entity",
"detail": "Validation failed",
"errors": {
"email": "Invalid email format",
"password": "Must be at least 8 characters"
}
}
}Nuorodos leidžia klientams naviguoti API be hardkodotų URL'ų:
{
"_links": {
"self": { "href": "/api/users/1" },
"collection": { "href": "/api/users" },
"posts": { "href": "/api/users/1/posts" },
"group": { "href": "/api/groups/1" },
"edit": { "href": "/api/users/1", "method": "PUT" },
"delete": { "href": "/api/users/1", "method": "DELETE" }
},
"_embedded": {
"user": { ... }
}
}Praktinis pavyzdys - PWA navigacija:
const response = await fetch('/api/users/1');
const data = await response.json();
const editUrl = data._links.edit.href;
const postsUrl = data._links.posts.href;Įdėtiniai resursai neleidžia N+1 užklausų problemų - viena užklausa gauna viską.
Su autoriumi (/api/posts/1?include=author):
{
"_links": { "self": { "href": "/api/posts/1" } },
"_embedded": {
"post": { "id": 1, "title": "Kaip sukurti REST API", "content": "..." },
"author": { "id": 1, "email": "admin@versliukai.lt", "roles": ["ROLE_ADMIN"] }
}
}Su visais ryšiais (/api/users/1?include=posts,group):
{
"_links": { "self": { "href": "/api/users/1" } },
"_embedded": {
"user": { "id": 1, "email": "admin@versliukai.lt" },
"posts": [
{ "id": 1, "title": "Kaip sukurti REST API" },
{ "id": 2, "title": "Middleware saugumas" }
],
"group": { "id": 1, "name": "Administratoriai" }
}
}src/
├── App/Kernel.php # tik inicializacija + middleware
├── Routing/
│ ├── AdrRoutes.php # API maršrutai
│ └── MvcRoutes.php # MVC maršrutai
├── Action/User/ # ADR veiksmai
│ ├── ListAction.php
│ └── ...
└── Controller/ # MVC kontroleriai
└── UserController.php
Transformers convert entities to HAL format.
// src/Transformer/Resource/UserTransformer.php
namespace App\Transformer\Resource;
use App\DTO\UserApiDTO;
use League\Fractal\TransformerAbstract;
class UserTransformer extends TransformerAbstract
{
public function transform(UserApiDTO $user): array
{
return [
'id' => $user->id,
'email' => $user->email,
'role' => $user->role,
'created_at' => $user->createdAt->format('c'),
];
}
}// src/Transformer/Resource/GroupTransformer.php
namespace App\Transformer\Resource;
use App\DTO\GroupApiDTO;
use League\Fractal\TransformerAbstract;
class GroupTransformer extends TransformerAbstract
{
public function transform(GroupApiDTO $group): array
{
return [
'id' => $group->id,
'name' => $group->name,
'created_at' => $group->createdAt->format('c'),
];
}
}use League\Fractal\Manager;
use League\Fractal\Resource\Collection;
use League\Fractal\Serializer\JsonApiSerializer;
$fractal = new Manager();
$fractal->setSerializer(new JsonApiSerializer());
$users = $this->repository->findAll();
$resource = new Collection($users, new UserTransformer());
$data = $fractal->createData($resource)->toArray();Unified Command/Handler pattern for MVC, ADR, and Console using League\Tactician.
Separates request handling from business logic:
- Commands - Simple objects representing intent (e.g.,
CreateUserCommand) - Handlers - Execute business logic (e.g.,
CreateUserHandler) - CommandBus - Dispatches commands to appropriate handlers
This enables:
- Single responsibility principle
- Easy testing (mock handlers)
- Consistent handling across MVC, ADR, and Console
src/
├── Command/ # Command objects
│ ├── User/
│ │ ├── ListUsersCommand.php
│ │ ├── GetUserCommand.php
│ │ ├── CreateUserCommand.php
│ │ ├── UpdateUserCommand.php
│ │ ├── PatchUserCommand.php
│ │ └── DeleteUserCommand.php
│ ├── Group/
│ │ ├── ListGroupsCommand.php
│ │ ├── GetGroupCommand.php
│ │ ├── CreateGroupCommand.php
│ │ ├── UpdateGroupCommand.php
│ │ ├── PatchGroupCommand.php
│ │ └── DeleteGroupCommand.php
│ ├── Console/
│ │ ├── ManageUserCommand.php
│ │ ├── CreateDatabaseCommand.php
│ │ ├── LoadFixturesCommand.php
│ │ └── GenerateProxiesCommand.php
│ └── CommandBusInterface.php # Interface for testing
├── Handler/ # Handler objects
│ ├── User/ # 6 handlers
│ ├── Group/ # 6 handlers
│ └── Console/ # 4 handlers
└── Container/
└── TacticianServiceProvider.php # DI setup
Created to enable mocking in tests (since League\Tactician\CommandBus is final):
// src/Command/CommandBusInterface.php
namespace App\Command;
interface CommandBusInterface
{
/**
* @param object $command
* @return mixed
*/
public function handle($command);
}// src/Action/User/ListAction.php
namespace App\Action\User;
use App\Command\CommandBusInterface;
use App\Command\User\ListUsersCommand;
use League\Fractal\Manager;
class ListAction
{
private CommandBusInterface $commandBus;
private Manager $fractal;
public function __construct(CommandBusInterface $commandBus, Manager $fractal)
{
$this->commandBus = $commandBus;
$this->fractal = $fractal;
}
public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$command = new ListUsersCommand(
limit: (int) ($request->getQueryParams()['limit'] ?? 10),
offset: (int) ($request->getQueryParams()['offset'] ?? 0),
search: $request->getQueryParams()['search'] ?? null
);
$users = $this->commandBus->handle($command);
// ... transform and return response
}
}# List all users
bin/console user:list
# Create user
bin/console user:create --email=user@example.com --password=secret
# Update user
bin/console user:update 1 --email=new@example.com
# Delete user
bin/console user:delete 1
# Manage user (interactive)
bin/console user:manage// tests/Action/UserActionTest.php
use App\Command\CommandBusInterface;
protected function setUp(): void
{
$this->commandBus = new class implements CommandBusInterface {
public function handle($command) {
return []; // Return mock data
}
};
}Fixtures provide test data generation.
// src/Console/Command/FixturesLoadCommand.php
// Creates: 3 groups (Developer, Designer, Tester)
// 3 roles (Wizard, Architect, GameMaster)
// N users with random gamification roles| Group | Role |
|---|---|
| TesterGroup | WizardRole |
| DesignerGroup | ArchitectRole |
| DeveloperGroup | GameMasterRole |
Each user gets one random role from the gamification scale.
// tests/Action/UserActionTest.php
public function testListActionReturnsHalJson(): void
{
$loader = new FixtureLoader();
$action = new ListAction($loader);
$result = $action($request, $response);
$this->assertEquals(200, $result->getStatusCode());
$this->assertEquals('application/hal+json', $result->getHeaderLine('Content-Type'));
}Middleware provides security, CORS, rate limiting.
// src/Middleware/SecurityMiddleware.php
namespace App\Middleware;
use Psr\Http.Server\MiddlewareInterface;
use Psr\Http.Server\RequestHandlerInterface;
use Psr\Http.Message\ServerRequestInterface;
use Psr\Http.Message\ResponseInterface;
class SecurityMiddleware implements MiddlewareInterface
{
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface {
$response = $handler->handle($request);
return $response
->withHeader('X-Content-Type-Options', 'nosniff')
->withHeader('X-Frame-Options', 'DENY')
->withHeader('X-XSS-Protection', '1; mode=block')
->withHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
->withHeader('Content-Security-Policy', "default-src 'self'");
}
}// src/Middleware/CorsMiddleware.php
namespace App\Middleware;
class CorsMiddleware implements MiddlewareInterface
{
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface {
if ($request->getMethod() === 'OPTIONS') {
return new JsonResponse(null, 204, [
'Access-Control-Allow-Origin' => '*',
'Access-Control-Allow-Methods' => 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
'Access-Control-Allow-Headers' => 'Content-Type, Authorization',
'Access-Control-Max-Age' => '86400',
]);
}
$response = $handler->handle($request);
return $response->withHeader('Access-Control-Allow-Origin', '*');
}
}// src/Middleware/RateLimitMiddleware.php
namespace App\Middleware;
class RateLimitMiddleware implements MiddlewareInterface
{
private int $maxRequests;
private int $windowSeconds;
public function __construct(int $maxRequests = 60, int $windowSeconds = 60)
{
$this->maxRequests = $maxRequests;
$this->windowSeconds = $windowSeconds;
}
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface {
$key = 'rate_limit:' . ($request->getHeaderLine('X-Forwarded-For') ?: 'local');
return $handler->handle($request)
->withHeader('X-RateLimit-Limit', (string) $this->maxRequests)
->withHeader('X-RateLimit-Remaining', (string) ($this->maxRequests - 1));
}
}$this->router->middleware(new SecurityMiddleware());
$this->router->middleware(new CorsMiddleware());
$this->router->middleware(new RateLimitMiddleware(100, 60));Monolog-based logging with environment-aware log levels and crash handling.
| Environment | Log Level | Log File |
|---|---|---|
dev |
Debug |
var/log/app_dev.log |
prod |
Error |
var/log/app_prod.log |
// src/Logger/LoggerFactory.php
namespace App\Logger;
class LoggerFactory
{
public static function create(string $appEnv = 'dev'): LoggerInterface
{
$level = match ($appEnv) {
'dev' => Level::Debug,
'prod' => Level::Error,
default => Level::Debug,
};
$logFile = 'var/log/app_' . $appEnv . '.log';
$logger = new Logger('app');
$logger->pushHandler(new StreamHandler($logFile, $level));
$logger->pushProcessor(new UidProcessor());
return $logger;
}
}use App\Logger\AppLogger;
use Psr\Log\LogLevel;
class UserController
{
public function __construct(private AppLogger $logger) {}
public function index(): array
{
$this->logger->info('User list accessed');
// ...
}
}Crash logger captures critical errors to var/log/crash.log:
// src/Logger/CrashLogger.php
class CrashLogger
{
private LoggerInterface $logger;
public function __construct()
{
$this->logger = LoggerFactory::createCrashLogger();
}
public function log(\Throwable $e): void
{
$this->logger->critical('Critical error', [
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTraceAsString(),
]);
}
}Crashes captured:
Error(all PHP errors)OutOfMemoryErrorSegmentation fault(via custom handler)Killed(SIGKILL signal)
// src/Kernel.php
set_error_handler(function ($severity, $message, $file, $line) {
if ($severity === E_ERROR) {
$crashLogger = LoggerFactory::createCrashLogger();
$crashLogger->critical("PHP Error: $message in $file:$line");
}
});
register_shutdown_function(function () {
$error = error_get_last();
if ($error !== null && in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR])) {
$crashLogger = LoggerFactory::createCrashLogger();
$crashLogger->critical("Fatal error: {$error['message']} in {$error['file']}:{$error['line']}");
}
});Progressive Web App capabilities with offline-first caching.
The PWA manifest defines how your app appears when installed:
| Property | Value |
|---|---|
| Name | Oryx ORM App |
| Short Name | OryxApp |
| Display | standalone |
| Start URL | / |
| Theme Color | #4A90E2 |
| Background Color | #ffffff |
{
"name": "Oryx ORM App",
"short_name": "OryxApp",
"description": "Full-stack ORM with ADR pattern",
"start_url" => "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#4A90E2",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}Located at public/sw.js with cache-first strategy:
- Pre-caches:
/,/index.php,/manifest.json,/favicon.ico, and icons - Serves cached assets when offline
- Automatically updates on new versions
After copying assets (Step 3 in Quick Start):
- Visit
http://localhost:8080/ - Browser will show "Install" prompt or use menu → "Add to Home Screen"
- Works offline after first visit
If you need to manually copy PWA assets:
# Manifest and Service Worker
cp -v ./vendor/oryx/mvc/public/manifest.json ./public/manifest.json
cp -v ./vendor/oryx/mvc/public/sw.js ./public/sw.js
# Icons (recursive copy)
cp -rv ./vendor/oryx/mvc/public/icons/ ./public/icons/.env.dist # Template defaults (committed to VCS) - all variables documented here
.env.yaml # Primary YAML config (RECOMMENDED)
.env # Legacy override (optional, gitignored)
.env.yaml.local # Local overrides (optional, gitignored)
Step 1: Copy the template:
cp .env.dist .env # Legacy KEY=VALUE format (optional)
cp .env.yaml .env.yaml.local # Local overrides (recommended)Step 2: Edit .env.yaml.local (recommended) or .env.yaml directly:
database:
driver: pdo_mysql
host: localhost
port: 3306
name: my_database
user: my_user
password: my_secretNOTE: YAML configuration (
.env.yaml) is the primary and recommended format. The legacy.envfile (KEY=VALUE) is supported for backward compatibility only. All available variables are documented in.env.dist.
- System environment variables -
$_ENV,$_SERVER .envfile - Legacy KEY=VALUE format (if present).env.yaml- Primary YAML configuration.env.dist- Template defaults
database:
host: ${DB_HOST:-localhost}
port: ${DB_PORT:-3306}
name: ${DB_NAME:-orm_db}
user: ${DB_USER:-root}
password: ${DB_PASSWORD:-}
charset: ${DB_CHARSET:-utf8mb4}
driver: ${DB_DRIVER:-pdo_sqlite}
path: ${DB_PATH:-var/data/orm.db}
app:
env: ${APP_ENV:-dev}
secret: ${APP_SECRET:-change-me-in-production}
orm:
proxy_dir: ${ORM_PROXY_DIR:-/tmp/orm/proxies}
proxy_namespace: ${ORM_PROXY_NAMESPACE:-Oryx\ORM\Proxy}| Syntax | Description | Example |
|---|---|---|
${VAR} |
Direct reference | ${DB_HOST} |
${VAR:-default} |
Default if not set | ${DB_HOST:-localhost} |
${VAR:?error} |
Error if not set | ${DB_PASSWORD:?Required} |
${nested.key} |
Nested reference | ${database.host} |
use App\EnvironmentConfig;
$config = new EnvironmentConfig();
$host = $config->get('DB_HOST', 'localhost');
$params = $config->getDatabaseParams();
if ($config->isDebug()) {
// Development mode
}
$secret = $config->require('APP_SECRET', 'Application secret is required');| Variable | Description | Default | Required |
|---|---|---|---|
DB_HOST |
Database hostname | localhost |
No |
DB_PORT |
Database port | 3306 |
No |
DB_NAME |
Database name | orm_db |
No |
DB_USER |
Database username | root |
No |
DB_PASSWORD |
Database password | (empty) | No |
DB_CHARSET |
Database charset | utf8mb4 |
No |
DB_DRIVER |
Database driver | pdo_sqlite |
No |
DB_PATH |
SQLite file path | var/data/orm.db |
No |
APP_ENV |
Application environment (dev/prod) | dev |
No |
APP_SECRET |
Application secret key | change-me-in-production |
No |
ORM_PROXY_DIR |
Proxy directory storage | /tmp/orm/proxies |
No |
ORM_PROXY_NAMESPACE |
Proxy namespace | Oryx\ORM\Proxy |
No |
CACHE_DRIVER |
Cache driver | array |
No |
CACHE_TTL |
Cache time-to-live (seconds) | 3600 |
No |
RATE_LIMIT_ENABLED |
Enable rate limiting | true |
No |
RATE_LIMIT_MAX_REQUESTS |
Max requests per window | 60 |
No |
RATE_LIMIT_WINDOW |
Rate limit window (seconds) | 60 |
No |
MAILER_TRANSPORT |
Mailer transport | smtp |
No |
MAILER_HOST |
Mailer hostname | localhost |
No |
MAILER_PORT |
Mailer port | 25 |
No |
MAILER_USER |
Mailer username | (empty) | No |
MAILER_PASSWORD |
Mailer password | (empty) | No |
All Doctrine XML mappings live in /schema:
schema/
├── User.orm.xml
├── Group.orm.xml
└── Role.orm.xml
schema/*.orm.xml → bin/console orm:generate:entities → src/Entity/*.php
<!-- schema/User.orm.xml -->
<doctrine-mapping>
<entity name="App\Entity\User" table="users">
<id name="id" type="integer">
<generator strategy="AUTO"/>
</id>
<field name="email" type="string" length="255" unique="true"/>
<field name="password" type="string" length="255"/>
<field name="createdAt" type="datetime"/>
<field name="updatedAt" type="datetime" nullable="true"/>
<many-to-one target-entity="App\Entity\Group" field="group" inversed-by="users">
<join-column name="group_id" nullable="true"/>
</many-to-one>
<one-to-many target-entity="App\Entity\UserRole" field="userRoles" mapped-by="user" cascade="persist" orphan-removal="true"/>
</entity>
</doctrine-mapping>Gamified role system su STI (Single Table Inheritance) ir grupių priskyrimu.
| Entity | Table | Description |
|---|---|---|
Role |
roles |
Base class (hidden from API) |
WizardRole |
roles |
STI (discriminator: wizard) |
ArchitectRole |
roles |
STI (discriminator: architect) |
GameMasterRole |
roles |
STI (discriminator: game_master) |
UserRole |
user_roles |
Join entity: user_id + role_id |
Group |
groups |
Base class (hidden from API) |
DeveloperGroup |
groups |
STI (discriminator: developer) |
DesignerGroup |
groups |
STI (discriminator: designer) |
TesterGroup |
groups |
STI (discriminator: tester) |
UserGroup |
user_groups |
Join entity: user_id + group_id |
Roles ir Groups naudoja STI - viena lentelė su discriminator stulpeliu:
// Role STI hierarchija
Role (bazinė - paslėpta nuo API)
├── WizardRole
├── ArchitectRole
└── GameMasterRole
// Group STI hierarchija
Group (bazinė - paslėpta nuo API)
├── DeveloperGroup
├── DesignerGroup
└── TesterGroupBazinės klasės yra paslėptos nuo API - DtoFactory meta RuntimeException, Repository filtruoja pagal discr stulpelį.
Kiekviena grupė turi susietą gamifikacinę rolę:
| Group | Role |
|---|---|
| TesterGroup | WizardRole |
| DesignerGroup | ArchitectRole |
| DeveloperGroup | GameMasterRole |
DtoFactory::create() automatiškai atpažįsta STI tipą per instanceof:
match (true) {
$entity instanceof WizardRole => WizardRoleApiDTO::fromEntity(...),
$entity instanceof ArchitectRole => ArchitectRoleApiDTO::fromEntity(...),
$entity instanceof GameMasterRole => GameMasterRoleApiDTO::fromEntity(...),
$entity instanceof Role => throw new RuntimeException('Base Role hidden'),
$entity instanceof DeveloperGroup => DeveloperGroupApiDTO::fromEntity(...),
$entity instanceof DesignerGroup => DesignerGroupApiDTO::fromEntity(...),
$entity instanceof TesterGroup => TesterGroupApiDTO::fromEntity(...),
$entity instanceof Group => throw new RuntimeException('Base Group hidden'),
}User:
{
"id": "uuid",
"email": "user@example.com",
"role": "ROLE_WIZARD",
"created_at": "2026-04-07T10:00:00+00:00"
}Group:
{
"id": "uuid",
"name": "Developers",
"created_at": "2026-04-07T10:00:00+00:00"
}GET /api/users/1
{
"_links": {
"self": { "href": "/api/users/1" },
"collection": { "href": "/api/users" }
},
"data": {
"id": "uuid",
"email": "user@example.com",
"role": "ROLE_WIZARD",
"created_at": "2026-04-07T10:00:00+00:00"
}
}| User | email, password, createdAt, userRoles, wands, patronuses, cloaks | 12 tests |
Group entity naudoja Single Table Inheritance (STI) kaip ir Role.
| Entity | Table | Discriminator | Description |
|---|---|---|---|
DeveloperGroup |
groups |
developer |
Developerių grupė |
DesignerGroup |
groups |
designer |
Designerių grupė |
TesterGroup |
groups |
tester |
Testerių grupė |
Bazinė Group klasė yra paslėpta nuo API - DtoFactory meta RuntimeException, Repository filtruoja pagal discr <> 'group'.
Vartotojai gali priklausyti kelioms grupėms per UserGroup join entity:
// User -> Group (per UserGroup)
$user->addGroup($group);
$user->removeGroup($group);
$user->hasGroup('Developers');
$user->getGroups(); // [DeveloperGroup, DesignerGroup]UserForm reikalauja group_id lauko:
// UserForm.php
$groupSpec = [
'name' => 'group_id',
'required' => true,
];schema/
├── Group.orm.xml # STI mapping
├── UserGroup.orm.xml # Many-to-many join
└── User.orm.xml # UserGroup OneToMany
DB-backed singleton registry with in-memory cache layer.
use App\Service\PersistentSingletonRegistry;
$registry = $container->get(PersistentSingletonRegistry::class);
// Store a value (serialized to DB)
$registry->set('my_service_config', ['timeout' => 30, 'retries' => 3]);
// Retrieve (cached in memory after first DB hit)
$config = $registry->get('my_service_config');
// Check existence
if ($registry->has('my_service_config')) {
// ...
}
// Remove
$registry->remove('my_service_config');┌─────────────────────────────────────────────────┐
│ PersistentSingletonRegistry │
├─────────────────────────────────────────────────┤
│ │
│ 1. Check in-memory $cache array │
│ ↓ (miss) │
│ 2. Query persistent_singleton table by key │
│ ↓ (found) │
│ 3. Unserialize value, store in cache │
│ ↓ │
│ 4. Return value │
│ │
│ On set(): │
│ 1. Serialize value │
│ 2. Upsert to persistent_singleton table │
│ 3. Update in-memory cache │
│ │
└─────────────────────────────────────────────────┘
| Scenario | Key Pattern | Value |
|---|---|---|
| Service config | config.service_name |
Array of settings |
| Feature flags | flag.feature_name |
Boolean |
| Rate limit state | rate_limit.ip_hash |
Counter array |
| Last processed ID | cursor.entity_type |
Integer |
CREATE TABLE persistent_singleton (
id CHAR(36) PRIMARY KEY,
key VARCHAR(255) UNIQUE NOT NULL,
value TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);Hybrid entity-backed file storage: metadata in DB, files on disk.
use App\Service\FlysystemService;
$fileService = $container->get(FlysystemService::class);
// Store file with metadata
$fileCache = $fileService->store(
key: 'user_avatar_123',
content: $imageData,
expiresAt: new \DateTimeImmutable('+30 days')
);
// Retrieve
$content = $fileService->retrieve('user_avatar_123');
// Check existence
if ($fileService->exists('user_avatar_123')) {
// ...
}
// Invalidate (deletes file + DB record)
$fileService->invalidate('user_avatar_123');
// Get metadata only
$metadata = $fileService->getMetadata('user_avatar_123');
echo $metadata->getSize(); // bytes
echo $metadata->getHash(); // sha256┌──────────────────────────────────────────────────────────┐
│ FlysystemService │
├──────────────────────────────────────────────────────────┤
│ │
│ store(key, content) │
│ ├── Write file → var/storage/cache/{key} │
│ ├── Create/update FileCache entity in DB │
│ │ - key, path, hash (sha256), size, expiresAt │
│ └── Emit FileCacheStored domain event │
│ │
│ retrieve(key) │
│ ├── Find FileCache entity by key │
│ ├── Check expiration → invalidate if expired │
│ └── Read file from Flysystem │
│ │
│ invalidate(key) │
│ ├── Delete file from Flysystem │
│ ├── Remove FileCache entity from DB │
│ └── Emit FileCacheInvalidated domain event │
│ │
└──────────────────────────────────────────────────────────┘
| Field | Type | Purpose |
|---|---|---|
id |
UUID | Primary key |
key |
string(255) | Unique cache key |
path |
string(512) | Flysystem path |
hash |
string(64) | SHA-256 checksum |
size |
integer | File size in bytes |
expiresAt |
datetime|null | Expiration (null = never) |
createdAt |
datetime | Creation timestamp |
updatedAt |
datetime | Last update timestamp |
Files stored in var/storage/cache/ (gitignored). Default adapter: LocalFilesystemAdapter.
var/storage/
└── cache/
├── user_avatar_123
├── report_2026_04_06
└── export_users_csv
| Event | When | Handler |
|---|---|---|
FileCacheStored |
After store() |
Logs key, path, size |
FileCacheInvalidated |
After invalidate() |
Logs key, path |
Doctrine lifecycle → domain events → async handlers.
Doctrine Entity Operation
↓
┌─────────────────────────────┐
│ DoctrineEventSubscriber │
│ (listens to Doctrine) │
│ - postPersist │
│ - postUpdate │
│ - postRemove │
└────────────┬────────────────┘
↓
┌─────────────────────────────┐
│ DomainEventEmitter │
│ emit() → sync dispatch │
│ emitAsync() → async queue │
└────────────┬────────────────┘
↓
┌─────────────────────────────┐
│ Symfony EventDispatcher │
│ (registered handlers) │
└─────────────────────────────┘
| Event | Triggered On | Data |
|---|---|---|
EntityCreated |
postPersist |
entityClass, entityId, full entity data |
EntityUpdated |
postUpdate |
entityClass, entityId, change set |
EntityDeleted |
postRemove |
entityClass, entityId |
use App\Event\DomainEvent;
use App\Event\DomainEventHandler;
use App\Event\EntityCreated;
class UserCreatedHandler implements DomainEventHandler
{
public function handle(DomainEvent $event): void
{
if (!$event instanceof EntityCreated) {
return;
}
if ($event->getEntityClass() !== \App\Entity\User::class) {
return;
}
$userId = $event->getEntityId();
$data = $event->getData();
// Send welcome email, create audit log, etc.
}
}// In your service provider or bootstrap
use App\Event\EntityCreated;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
$dispatcher->addListener(EntityCreated::class, [new UserCreatedHandler(), 'handle']);// DomainEventEmitter supports both sync and async
$emitter->emit($event); // Immediate, blocking
$emitter->emitAsync($event); // Queued for background processing
// Async events prefixed with 'async.' in dispatcher
$dispatcher->addListener('async.' . EntityCreated::class, [
new AsyncUserCreatedHandler(),
'handle'
]);// EntityCreated
[
'entityClass' => 'App\Entity\User',
'entityId' => '550e8400-e29b-41d4-a716-446655440000',
'data' => [
'email' => 'user@example.com',
'createdAt' => '2026-04-06T10:00:00+00:00',
// ... other scalar properties
],
'occurredOn' => '2026-04-06T10:00:00+00:00',
]
// EntityUpdated
[
'entityClass' => 'App\Entity\User',
'entityId' => '550e8400-e29b-41d4-a716-446655440000',
'changes' => [
'email' => ['old@example.com', 'new@example.com'],
'updatedAt' => [null, '2026-04-06T10:00:00+00:00'],
],
'occurredOn' => '2026-04-06T10:00:00+00:00',
]Background processing for async events with failure logging.
DomainEventEmitter::emitAsync(event)
↓
AsyncEventBus.spawnSubprocess(event)
↓
exec("php bin/async-event-worker.php <base64_payload>") // fire-and-forget
↓
[bin/async-event-worker.php]
├── Bootstrap minimal app (EntityManager, EventDispatcher)
├── Deserialize event from argv[1]
├── Dispatch to 'async.' . event class name
└── Log failures to var/log/async_failures.log
use App\Event\DomainEventEmitter;
// Emit sync (same request)
$emitter->emit($event);
// Emit async (background process)
$emitter->emitAsync($event);use App\Event\DomainEvent;
use App\Event\DomainEventHandler;
use App\Event\EntityCreated;
class AsyncUserCreatedHandler implements DomainEventHandler
{
public function handle(DomainEvent $event): void
{
if (!$event instanceof EntityCreated) {
return;
}
if ($event->getEntityClass() !== \App\Entity\User::class) {
return;
}
// Heavy processing that shouldn't block request:
// - Send welcome email (queued)
# - Generate thumbnails
# - Update search index
# - Call external APIs
}
}// In your service provider or bootstrap
use App\Event\EntityCreated;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
// Handler registered for async.* events
$dispatcher->addListener('async.' . EntityCreated::class, [
new AsyncUserCreatedHandler(),
'handle'
]);Async event failures are logged to var/log/async_failures.log with:
- Full exception message and traceback
- Event type and serialized payload
- Timestamp with UID for correlation
- The worker exits with code 1 on failure (logged but doesn't block main request)
| Log File | Purpose | Level |
|---|---|---|
var/log/app_{env}.log |
ORM events via ORMEventListener | debug, info |
var/log/async_failures.log |
Failed async events | error |
Get started with the hybrid entity-backed file cache in 60 seconds.
-
Ensure Flysystem is installed (already in composer.json):
composer require league/flysystem
-
Create storage directory (gitignored):
mkdir -p var/storage/cache chmod 755 var/storage/cache
-
Run migrations to create
file_cachetable:php bin/console doctrine:migrations:migrate
use App\Service\FlysystemService;
$fileService = $container->get(FlysystemService::class);
// Store a file with metadata
$fileCache = $fileService->store(
key: 'user_avatar_123',
content: $fileContents,
expiresAt: new \DateTimeImmutable('+30 days')
);
// Retrieve file contents
$contents = $fileService->retrieve('user_avatar_123');
// Check if file exists and is not expired
if ($fileService->exists('user_avatar_123')) {
// File is ready to use
}
// Get metadata only (doesn't read file)
$metadata = $fileService->getMetadata('user_avatar_123');
echo "Size: {$metadata->getSize()} bytes";
echo "SHA-256: {$metadata->getHash()}";
// Invalidate cache (deletes file + DB record)
$fileService->invalidate('user_avatar_123');With expiration:
// Expires in 1 hour
$fileService->store('temp_report_456', $reportData, new \DateTimeImmutable('+1 hour'));
// Never expires
$fileService->store('permanent_config_789', $configData);Error handling:
try {
$fileService->store('important_file', $data);
} catch (\RuntimeException $e) {
// Handle storage failures (disk full, permissions, etc.)
$logger->error('File storage failed', ['error' => $e->getMessage()]);
}Event integration:
use App\Event\FileCacheStored;
use App\Event\FileCacheInvalidated;
// Listen to file cache events
$dispatcher->addListener(FileCacheStored::class, [$handler, 'onFileCacheStored']);
$dispatcher->addListener(FileCacheInvalidated::class, [$handler, 'onFileCacheInvalidated']);Files are stored in var/storage/cache/ with safe filenames:
var/storage/
└── cache/
├── user_avatar_123
├── report_2026_04_06
├── temp_export_xyz
└── permanent_config_789
The file_cache table is created automatically via migrations:
CREATE TABLE file_cache (
id CHAR(36) PRIMARY KEY,
key VARCHAR(255) UNIQUE NOT NULL,
path VARCHAR(512) NOT NULL,
hash CHAR(64),
size INTEGER NOT NULL,
expires_at DATETIME NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);Essential ignores for development and production.
/vendor/ # Composer dependencies
/var/log/ # Application logs
/var/storage/ # Flysystem file storage
/var/data/ # SQLite databases and data files
reports/ # Test coverage reports
coverage/ # PHPUnit coverage output
.idea/ # JetBrains IDE settings
.vscode/ # VS Code settings
*.swp # Vim swap files
*.log # Log files
composer.lock # Lock file for reproducible builds
.php-cs-fixer.cache # PHP-CS-Fixer cache
.php-cs-fixer.php # PHP-CS-Fixer config
phpunit.xml # PHPUnit configuration
.phpactor.json # PHP Actor configuration
/var/data/orm.db # SQLite ORM database
.env.yaml.local # Local environment overrides
/public/manifest.json # PWA manifest
/public/sw.js # Service worker
/public/icons/ # PWA icons
auth.json # Private repository auth
- Dependencies:
/vendor/managed by Composer - Logs:
/var/log/contains runtime application logs - Storage:
/var/storage/contains user-uploaded/cached files - Data:
/var/data/contains SQLite databases - IDE Configs: Personal editor settings
- Cache Files: Tool-generated cache files
- Lock Files:
composer.lockfor reproducible builds - Environment: Local overrides should not be committed
- PWA Assets: Users should customize manifests and service workers
- Auth: Private credentials for paid packages
Get a development environment running in under 30 seconds.
- PHP 8.2+ with SQLite extension
- Composer 2.0+
# 1. Clone repository
git clone <repository-url>
cd orm-develop
# 2. Install dependencies
composer install
# 3. Copy environment template
cp .env.dist .env
# 4. Create required directories
mkdir -p var/log var/storage/cache var/data
# 5. Run migrations
php bin/console doctrine:migrations:migrate
# 6. Load test data (optional)
php bin/console doctrine:fixtures:load# Start PHP built-in server
php -S localhost:8080 -t public
# Visit: http://localhost:8080| Path | Method | Description |
|---|---|---|
/ |
GET | Home page with system info |
/api/users |
GET | List users (JSON:HAL) |
/api/users/{id} |
GET | Get user details |
/mvc/users |
GET | MVC user list page |
/mvc/users/create |
GET/POST | Create user form |
/mvc/groups |
GET | MVC Baltimore groups page |
| Command | Description |
|---|---|
php bin/console |
List all available commands |
php bin/console doctrine:migrations:migrate |
Run database migrations |
php bin/console doctrine:fixtures:load |
Load test fixtures |
php bin/console cache:status |
Check cache status |
php bin/console cache:clear |
Clear application cache |
php bin/console benchmark:run |
Run performance benchmarks |
# Run all tests
composer test
# Run specific test suite
vendor/bin/phpunit tests/Unit/EntityManagerTest.php
# Run with coverage
vendor/bin/phpunit --coverage-html reports/coveragePermission denied on var/storage/:
chmod -R 755 var/storage/
chown -R $USER:$USER var/storage/SQLite database locked:
# Check for stale processes
lsof var/data/orm.db
# Kill conflicting processes or restartMissing dependencies:
composer install --no-interaction --prefer-dist| Layer | Pattern | HTTP | Templates | Dependencies |
|---|---|---|---|---|
| MVC | Controller → Model → View | Vanilla PHP | PHP | Doctrine ORM |
| ADR | Action → Domain → Responder | PSR-7 (JsonApiResponder base) |
JSON:HAL | League Fractal |
| PWA | Service Worker + Manifest | Both | Cache | Offline-first |
Key Files:
├── public/
│ ├── index.php # Unified entry (routes MVC ↔ ADR)
│ ├── mvc.php # MVC only
│ └── api.php # ADR only
├── src/
│ ├── Http/ # Vanilla MVC HTTP (no dependencies)
│ ├── Controller/ # MVC Controllers
│ ├── View/ # PHP Templates
│ ├── Action/ # ADR Actions
│ ├── Responder/ # JSON:HAL Responders
│ ├── Transformer/ # League Fractal Transformers
│ ├── Entity/ # Doctrine Entities (12)
│ │ ├── User.php
│ │ ├── Group.php # STI base (hidden from API)
│ │ ├── DeveloperGroup.php # STI
│ │ ├── DesignerGroup.php # STI
│ │ ├── TesterGroup.php # STI
│ │ ├── Role.php # STI base (hidden from API)
│ │ ├── WizardRole.php # STI
│ │ ├── ArchitectRole.php # STI
│ │ ├── GameMasterRole.php # STI
│ │ ├── UserRole.php # join entity
│ │ └── UserGroup.php # join entity
│ └── Fixture/ # League Factory Muffin
├── schema/ # Doctrine XML mappings
│ ├── User.orm.xml
│ ├── Group.orm.xml # STI mapping
│ ├── UserGroup.orm.xml # Many-to-many
│ ├── Role.orm.xml
│ ├── UserRole.orm.xml
│ ├── DeveloperGroup.orm.xml
│ ├── DesignerGroup.orm.xml
│ └── TesterGroup.orm.xml
├── templates/ # MVC PHP templates
│ ├── home.php
│ ├── users/
│ └── error/
└── tests/
├── Action/ # ADR Action Tests
├── Unit/ # Unit Tests
│ ├── GroupInheritanceTest.php # STI drill tests
│ ├── UserRolesCollectionTest.php
│ ├── DoctrineCollectionAdvancedTest.php
│ └── RoleCollectionTest.php
└── factories/ # Factory Definitions
"The best architecture is the one that fits your needs." — Unknown your needs."* — Unknown