| name | dev-guide |
| description | Expert guide for working with the byjg/php-gluo project. Use this skill whenever the user is working on this project โ including setting up the dev environment, creating new CRUD features, scaffolding with the code generator, modifying or fixing existing features, writing or updating tests, running migrations, and maintaining OpenAPI documentation. Trigger this skill for any task related to models, repositories, services, REST controllers, attributes, authentication, DI configuration, or the byjg framework stack in this codebase. Even if the user says something generic like "add a new endpoint" or "fix this bug", use this skill if they're working in this project.
|
PHP REST Reference Architecture โ Development Skill
This is a production-ready PHP REST API template (not a framework). You own it completely
and modify it freely. The focus is clean separation of concerns, OpenAPI-first design, and
testability.
Core libraries: byjg/restserver (routing), byjg/micro-orm (ORM), byjg/authuser
(JWT auth), byjg/config (DI container), byjg/migration (DB migrations)
How the Stack Connects
Understanding the request lifecycle prevents confusion when debugging or extending:
HTTP Request
โ
โผ
JwtMiddleware โ byjg/restserver middleware, parses/validates JWT,
โ stores decoded claims as request param "jwt.data"
โผ
OpenApiRouteList โ matches URL+method to controller class::method
โ (generated from public/docs/openapi.json)
โผ
PHP Attribute Chain โ run BEFORE the controller method:
#[RequireAuthenticated] โ verifies JWT present; calls JwtContext::setRequest()
#[RequireRole("admin")] โ verifies JWT role claim; calls JwtContext::setRequest()
#[ValidateRequest] โ validates body against OpenAPI schema; stores payload
โ
โผ
Controller method โ receives (HttpResponse $response, HttpRequest $request)
โ Config::get(ProductService::class) โ PSR-11 DI container lookup
โ ValidateRequest::getPayload() โ validated, null-stripped array
โ $request->attribute('id') โ path param OR jwt.data sub-key
โ $request->query('page') โ query string param
โ
โผ
Service โ business logic; wraps repository (Repository pattern)
โ
โผ
Repository โ data access; wraps ByJG\MicroOrm\Repository
โ
โผ
DatabaseExecutor โ connection pool / transactions
โ
โผ
MySQL
DI container initialization order (config files load in this exact sequence):
01-infrastructure.php โ DB driver, DatabaseExecutor, ORM setup, logging
02-security.php โ JWT, password policy, UsersService/Repository, CORS list
03-api.php โ OpenApiRouteList, JwtMiddleware, CorsMiddleware, HttpRequestHandler
04-repositories.php โ your feature repositories
05-services.php โ your feature services
ActiveRecord models rely on "ORMInitialization" (a toEagerSingleton() in 01-infrastructure.php)
which calls ORM::defaultDbDriver() at startup. This happens automatically โ you don't call it yourself.
Environment Setup
First-time clone / full reset
git fetch && git pull && git merge origin/master
composer update
docker compose up -d
composer migrate -- --env=dev reset
php vendor/bin/psalm
php vendor/bin/phpunit
Every subsequent development cycle
git fetch && git pull && git merge origin/master
composer update
docker compose up -d
composer migrate -- --env=dev update
php vendor/bin/psalm
php vendor/bin/phpunit
When done: docker compose down
Project Structure
src/
โโโ Controller/ # HTTP controllers โ attribute-based routing
โโโ Service/ # Business logic โ wraps repositories (Repository pattern only)
โโโ Repository/ # Data access โ queries and persistence
โโโ Model/ # Database models with ORM + OpenAPI attributes
โโโ OpenApiSpec.php # Root OpenAPI spec definition
# Attributes (RequireAuthenticated, ...), traits (OaCreatedAt, ...), base
# classes and utilities live in the byjg/gluo-core package (ByJG\Gluo\*).
config/{env}/
โโโ 01-infrastructure.php # DB, cache, logging, ORM init
โโโ 02-security.php # JWT, password policy, auth user stack
โโโ 03-api.php # HTTP handler, middleware, routing
โโโ 04-repositories.php # Repository DI bindings
โโโ 05-services.php # Service DI bindings
โโโ 06-external.php # External services (mail, etc.)
db/
โโโ base.sql # Base schema + seed users
โโโ migrations/
โโโ up/ # Forward SQL files (00001.sql, 00002.sql, ...)
โโโ down/ # Rollback SQL files
Architecture Decision: Repository Pattern vs ActiveRecord
Always ask the user which they want before building.
Repository Pattern (more layers, more control)
Controller โ Service โ Repository โ Model
- Use when: complex business logic, validation, multiple repos, team projects
- Files: Model + Repository + Service + Controller (4 files + DI registrations + tests)
- Reference:
src/Controller/DummyController.php, src/Repository/DummyRepository.php
ActiveRecord Pattern (fewer layers, simpler)
Controller โ Model (handles its own persistence)
- Use when: simple CRUD, prototyping, admin panels
- Files: Model + Controller (2 files + no DI registrations needed + tests)
- Reference:
src/Controller/DummyActiveRecordController.php, src/Model/DummyActiveRecord.php
Creating a New Feature
Option A: Codegen (recommended starting point)
composer migrate -- --env=dev update
composer codegen -- --env=dev --table=product all --save
composer run openapi
Codegen produces: model, repository, service, controller, and tests. Edit to add business logic.
Option B: Manual โ Repository Pattern
1. Migration
db/migrations/up/XXXX.sql (increment from last file):
CREATE TABLE product (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(120) NOT NULL,
price DECIMAL(10,2) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL
) ENGINE=InnoDB;
db/migrations/down/XXXX.sql: DROP TABLE product;
Apply: composer migrate -- --env=dev update
2. Model
#[OA\Schema(required: ["name"], type: "object")]
#[TableAttribute("product")]
class Product
{
use OaCreatedAt, OaUpdatedAt, OaDeletedAt;
#[OA\Property(type: "integer", format: "int32")]
#[FieldAttribute(primaryKey: true, fieldName: "id")]
protected int|null $id = null;
#[OA\Property(type: "string", maxLength: 120)]
#[FieldAttribute(fieldName: "name")]
protected string|null $name = null;
public function getId(): int|null { return $this->id; }
public function setId(int|null $id): static { $this->id = $id; return $this; }
public function getName(): string|null { return $this->name; }
public function setName(string|null $name): static { $this->name = $name; return $this; }
}
Available traits: use OaCreatedAt; / use OaUpdatedAt; / use OaDeletedAt;
UUID primary key: use #[TableMySqlUuidPKAttribute("product")] and #[FieldUuidAttribute(primaryKey: true)]
(see src/Model/DummyHex.php for the complete UUID model pattern)
3. Repository
class ProductRepository extends BaseRepository
{
public function __construct(DatabaseExecutor $executor)
{
$this->repository = new Repository($executor, Product::class);
}
}
BaseRepository provides: get($id), list($page, $size), save($model), delete($id), getByQuery($query).
Custom query:
use ByJG\MicroOrm\Query;
public function getByName(string $name): array
{
$query = Query::getInstance()
->table('product')
->where('product.name = :name', ['name' => $name]);
return $this->repository->getByQuery($query);
}
See references/queries.md for advanced query patterns (joins, ordering, filtering).
4. Service (Repository pattern only)
class ProductService extends BaseService
{
public function __construct(ProductRepository $repository)
{
parent::__construct($repository);
}
}
5. Register in DI Container
config/dev/04-repositories.php:
use ByJG\Config\DependencyInjection as DI;
ProductRepository::class => DI::bind(ProductRepository::class)
->withInjectedConstructor()
->toSingleton(),
config/dev/05-services.php:
ProductService::class => DI::bind(ProductService::class)
->withInjectedConstructor()
->toSingleton(),
Repeat for config/test/ (required for tests to work).
6. REST Controller
class ProductController
{
#[OA\Get(path: "/product/{id}", security: [["jwt-token" => []]], tags: ["product"])]
#[OA\Parameter(name: "id", in: "path", required: true, schema: new OA\Schema(type: "integer"))]
#[OA\Response(response: 200, description: "Success",
content: new OA\JsonContent(ref: "#/components/schemas/Product"))]
#[OA\Response(response: 404, description: "Not Found",
content: new OA\JsonContent(ref: "#/components/schemas/error"))]
#[RequireAuthenticated]
public function getProduct(HttpResponse $response, HttpRequest $request): void
{
$service = Config::get(ProductService::class);
$result = $service->getOrFail($request->attribute('id'));
$response->write($result);
}
#[OA\Post(path: "/product", security: [["jwt-token" => []]], tags: ["product"])]
#[OA\RequestBody(required: true, content: new OA\JsonContent(ref: "#/components/schemas/Product"))]
#[OA\Response(response: 200, description: "Created",
content: new OA\JsonContent(ref: "#/components/schemas/Product"))]
#[RequireAuthenticated]
#[ValidateRequest]
public function postProduct(HttpResponse $response, HttpRequest $request): void
{
$service = Config::get(ProductService::class);
$model = $service->create(ValidateRequest::getPayload());
$response->write($model);
}
}
Request helpers:
$request->attribute('id') โ path param (e.g. {id}) or JWT decoded claim (e.g. $request->attribute('jwt.data'))
$request->query('page') โ query string param
ValidateRequest::getPayload() โ validated request body (array for JSON)
Security attributes:
#[RequireAuthenticated] โ any valid JWT token
#[RequireRole(User::ROLE_ADMIN)] โ JWT + specific role
#[ValidateRequest] โ validates body against OpenAPI schema
7. Regenerate OpenAPI
composer run openapi
Always run this after adding or changing controller attributes. It updates public/docs/openapi.json
which drives both routing and contract testing.
openapi.json is the single source of truth: OpenApiRouteList reads it to build the route
table (URL+method โ Controller::method), and #[ValidateRequest] reads it to validate request
bodies. See references/request-response.md for the full pipeline, content negotiation, and
OpenAPI attribute patterns.
8. Tests
See references/testing.md for a complete test guide.
Quick pattern โ extend BaseApiTestCase and use FakeApiRequester:
class ProductTest extends BaseApiTestCase
{
public function testGetUnauthorized(): void
{
$this->expectException(Error401Exception::class);
$request = (new FakeApiRequester())
->withPsr7Request($this->getPsr7Request())
->withMethod('GET')->withPath('/product/1')
->expectStatus(401);
$this->sendRequest($request);
}
public function testCreate(): void
{
$loginResult = json_decode(
$this->sendRequest(Credentials::requestLogin(Credentials::getAdminUser()))
->getBody()->getContents(),
true
);
$token = $loginResult['token'];
$body = $this->sendRequest(
(new FakeApiRequester())
->withPsr7Request($this->getPsr7Request())
->withMethod('POST')->withPath('/product')
->withRequestBody(json_encode(['name' => 'Widget']))
->withRequestHeader(['Authorization' => "Bearer $token"])
->expectStatus(200)
);
$result = json_decode($body->getBody()->getContents(), true);
$this->assertNotEmpty($result['id']);
}
}
Look at tests/Controller/DummyTest.php for a complete reference implementation.
Writing Responses and Converting Objects
$response->write($model);
use ByJG\Serializer\Serialize;
$publicData = Serialize::from($model)->withIgnoreProperties(['secret'])->toArray();
$response->write($publicData);
$data = Serialize::from($model)->withOnlyProperties(['id', 'name'])->toArray();
$data = Serialize::from($model)->withDoNotParseNullValues()->toArray();
use ByJG\Serializer\ObjectCopy;
ObjectCopy::copy($arrayData, $product);
BaseService::create() and update() already call ObjectCopy internally โ you don't need
to call it manually in those flows.
See references/serialization.md for full patterns including role-based field selection,
null stripping, and case-transformation handlers.
Authentication Patterns
JWT is decoded by JwtMiddleware and stored as request params. Access it in any
#[RequireAuthenticated] or #[RequireRole] protected method:
use ByJG\Gluo\Util\JwtContext;
$userId = JwtContext::getUserId();
$role = JwtContext::getRole();
$name = JwtContext::getName();
Role constants: User::ROLE_ADMIN, User::ROLE_USER
Login flow (implemented in src/Controller/LoginController.php):
POST /login with {username, password} โ JwtContext::createUserMetadata() validates via UsersService
- Returns
{token, data: {userid, name, role}}
- Client sends
Authorization: Bearer <token> on subsequent requests
JwtMiddleware validates signature on every request, stores decoded claims
Role-Based Responses
When an endpoint must return different fields by role, pick one approach first:
- Two endpoints (recommended):
GET /product (own data from JWT) + GET /product/{id} (admin)
oneOf schema: single URL with two documented schemas
- Nullable admin fields: simpler but conflates two audiences
See the skill's existing documentation in the current SKILL for full oneOf example.
Error Handling
Throw to return the right HTTP status:
Error400Exception โ Bad Request
Error401Exception โ Unauthorized
Error403Exception โ Forbidden
Error404Exception โ Not Found (getOrFail() throws this automatically)
Error422Exception โ Unprocessable Entity (validation errors)
Error520Exception โ Internal error
Modifying Existing Features
- Read the existing code first (model, repository, service, controller, tests)
- Make changes at the appropriate layer
- Write a new migration if DB schema changes
composer run openapi after any controller attribute changes
php vendor/bin/psalm โ fix type errors
php vendor/bin/phpunit โ all tests must pass
- Update tests to reflect new behavior
Key Commands Reference
| Command | Purpose |
|---|
docker compose up -d | Start MySQL + PHP containers |
docker compose down | Stop containers |
composer update | Update PHP dependencies |
php vendor/bin/psalm | Run static analysis |
php85 vendor/bin/psalm | Psalm fallback (if default is buggy) |
php vendor/bin/phpunit | Run test suite |
composer run openapi | Regenerate OpenAPI spec from attributes |
composer migrate -- --env=dev update | Apply pending migrations (normal dev) |
composer migrate -- --env=dev reset | Wipe and recreate DB (first install / CI) |
composer codegen -- --env=dev --table=X all --save | Scaffold full CRUD for table X |
After Every Change Checklist
Reference Files
references/queries.md โ advanced ORM queries, joins, filtering, pagination
references/testing.md โ complete testing patterns and cheatsheet
references/serialization.md โ Serialize and ObjectCopy: response shaping, field filtering, hydrating entities
references/request-response.md โ OpenAPI routing, ValidateRequest, input/output content negotiation
references/openapi-patterns.md โ Edge-case OpenAPI patterns: enums, formats, oneOf, nullable, nested schemas, additionalProperties, custom attribute classes
references/http-client.md โ Outbound HTTP with byjg/uri + byjg/webrequest (prefer over Guzzle)
references/di-environments.md โ Environment system, config loading order, DI binding methods, Param::get()
references/xml.md โ XML input (XmlDocument) and output, OpenAPI attributes, pitfalls
references/cache.md โ Cache engines, DI wiring per environment, caching ORM queries and OpenAPI routes
references/users.md โ Extending User model, properties, password hashing, JWT payload customization
references/email.md โ Sending email, templates, provider URIs, FakeSenderWrapper for tests
references/jinja.md โ byjg/jinja-php syntax, supported filters/tags, and what is NOT available vs Python Jinja2
references/anydataset.md โ Unified row/iterator abstraction: in-memory, XML, JSON, CSV/fixed-width, NoSQL; install instructions for optional extensions
references/imageutil.md โ GD-based image manipulation: resize, crop, rotate, flip, watermark, text overlay, saving; install instructions
references/statemachine.md โ Finite state machine: workflow transitions, autoTransitionFrom for classification, DI registration pattern
references/messagequeue.md โ Message queues (RabbitMQ, Redis, mock): publish, consume, DLQ, ACK/NACK, DI wiring, test mock
references/scriptify.md โ CLI scripts, cron jobs, and systemd services: run any class::method from the command line or install as a daemon
references/featureflags.md โ Feature flags: PHP attribute approach, DI registration, dispatcher-per-handler-class pattern, testing with clearFlags()