Skip to main content

Coming from another PHP framework

Gluo is a REST API starter, not a full framework. Most of what you know transfers — PSR-4, Composer, PHPUnit, controllers, repositories, services, a DI container. This page maps the vocabulary of Laravel, Symfony, Drupal, CakePHP and CodeIgniter, and calls out the handful of places where Gluo genuinely works differently, so you do not lose an afternoon to a surprise.

If you only read one section, read The router is generated below. It is the single most common source of "why does my endpoint 404".

The four real differences​

1. The router is generated — attributes are the source, JSON is the truth​

Routes are not read from your PHP classes at runtime. The #[OA\...] attributes on your controllers produce public/docs/openapi.json, and php gluo openapi compiles it into generated/routes.php: that file is the routing table (CompiledRouteList).

#[OA\Get(path: "/project/{id}", tags: ["Project"])]
#[RequireAuthenticated]
public function getProject(HttpResponse $response, HttpRequest $request): void

So adding or changing an endpoint is a two-step operation:

# 1. edit the controller attributes
# 2. rebuild the routing table
php gluo openapi

Skip step 2 and the endpoint returns 404 with no error — the route simply does not exist yet. To catch this, php gluo test runs a freshness check first, and you can run it any time:

php gluo openapi:check

It fails, and php gluo test with it, when the spec is not what the attributes generate: it generates the spec in memory and compares it with public/docs/openapi.json. Run php gluo openapi, review the change and commit it.

Laravel: there is no routes/api.php. Symfony: it resembles #[Route], except the "cache rebuild" is explicit and you own it. Drupal: it plays the role of mymodule.routing.yml, except you never edit it by hand. CakePHP: it replaces config/routes.php. CodeIgniter: it replaces app/Config/Routes.php. As with auto-routing turned off (the default since 4.2), every route is declared, but the declaration lives on the controller method.

2. Autowire is a developer decision​

Gluo does not impose one style. For each namespace you choose: let the container resolve classes for you with a pattern rule, or accept only the bindings you declared. It is a trade-off between performance and convenience. An explicit binding resolves with a plain lookup. A class that is only matched by a rule costs a class_exists() check and a pattern match on each resolution, and it hides the decisions an explicit binding would show.

Constructor injection works the way you expect. Controllers declare what they need and the container provides it:

class ProjectController
{
public function __construct(protected ProjectService $projectService)
{
}

public function getProject(HttpResponse $response, HttpRequest $request): void
{
$response->write($this->projectService->getOrFail($request->attribute('id')));
}
}

The starter autowires controllers, because they are terminal classes with nothing to decide: one implementation, named directly by the router, always per-request. One pattern rule covers the namespace:

// config/dev/07-controllers.php
'RestReferenceArchitecture\Controller\*' => Autowire::rule()
->withInjectedConstructor() // resolve constructor args from their type hints
->toInstance(), // per-request, not shared

Services and repositories are declared one by one, because those bindings carry decisions a pattern cannot make: which implementation, singleton or not, scalar constructor arguments:

// config/dev/05-services.php
ProjectService::class => DI::bind(ProjectService::class)
->withInjectedConstructor()
->toSingleton(),

These are the starter's defaults, not rules. You can add an Autowire::rule() for any other namespace, or remove the controller rule and bind each controller yourself. An explicit binding always wins over a pattern, so you can also use a rule and still declare the complex cases by hand. php gluo codegen:all writes the service and repository entries for what it generates.

config/test/ inherits config/dev/, so you register once for both.

Symfony: the controller rule is your App\Controller\: resource: line; the difference is that the starter declares each service instead. Laravel: think explicit $this->app->bind() for services, with controllers resolved for you. Drupal: like mymodule.services.yml, except constructor arguments resolve from type hints and controllers need no entry. CakePHP: the same choice you make in Application::services(). With league/container, autowiring stays off until you delegate to a ReflectionContainer. CodeIgniter: there is no container. The Config\Services factories wire dependencies by hand, and here the container resolves them from type hints.

3. Controllers write to a response, they do not return one​

The signature is fixed and the return type is void:

public function listProject(HttpResponse $response, HttpRequest $request): void
{
$response->write($result); // arrays and objects are serialized for you
}

No return response()->json(...), no return new JsonResponse(...), no render arrays, no viewBuilder()->setOption('serialize', ...), no return $this->response->setJSON(...).

4. The database comes first, and migrations are plain SQL​

The code generator reads your existing table and writes the PHP:

php gluo --env=dev codegen:all --table=product --save

That emits the model, repository, service, controller, functional test and DI bindings. The controller has get, list, create and update, working; the delete endpoint is the one you write yourself (Symfony's make:crud generates it, Laravel generates none of the bodies). Migrations are hand-written numbered SQL pairs — you write both directions:

db/migrations/up/00002-create-products.sql
db/migrations/down/00001-rollback-products.sql

Laravel: the direction is reversed. You are used to model → migration → table; here it is table → codegen → model. There is no schema builder. Symfony: closer to doctrine:mapping:import than to make:entity. Drupal: replaces hook_schema() and hook_update_N(). CakePHP: the direction will feel familiar, because bin/cake bake all also starts from an existing table. The difference is the migrations: SQL files instead of config/Migrations classes. CodeIgniter: make:scaffold creates empty classes, while codegen reads the table and fills them in. Migrations are SQL files instead of Forge classes.

Persistence: Repository or ActiveRecord, per entity​

byjg/micro-orm implements the three patterns you already know: Data Mapper, Repository and ActiveRecord. The mapper is built from the attributes on the model (#[TableAttribute], #[FieldAttribute]), and both styles use it. You pick a style per entity, not per project:

  • Repository + Data Mapper: Controller → Service → Repository → Model. The model is a plain object, and the repository owns the queries. Use it when there is business logic.
  • ActiveRecord: Controller → Model. The model uses the ActiveRecord trait and saves itself, with no service or repository class. Use it for simple CRUD.
You are used toPatternClosest Gluo style
Laravel — EloquentActiveRecordActiveRecord
Symfony — DoctrineData Mapper + RepositoryRepository + Data Mapper
Drupal — Entity API + EntityStorageRepositoryRepository + Data Mapper
CakePHP — Table + EntityRepository (Table) + EntityRepository + Data Mapper
CodeIgniter — ModelTable gateway + Query BuilderActiveRecord, Repository once logic grows

Both styles live in the same project, so a Laravel developer can keep the Eloquent feel for simple tables while the complex ones get a service layer. See Architecture Decisions for when to choose which.

What you get in the box​

What a new project already does before you write a line, compared with what you would add or generate elsewhere. See it side by side shows each one in a real application.

  • Login, done. POST /login returns a JWT. For basic login that is what Laravel's starter kit (Fortify) or Symfony's make:user + make:security:form-login give you. Beyond the login itself, Gluo also ships token refresh, password reset by an e-mail code, and roles. Laravel's starter kit has password reset too (plus registration, e-mail verification and 2FA); Symfony's two makers do not, and make:reset-password adds it with a SymfonyCasts bundle.
  • Permissions per endpoint. #[RequireAuthenticated] and #[RequireRole] on the method, so reading and writing can have different permissions without a policy or a firewall rule. The generated CRUD lets any logged-in user read and only admins write; Laravel and Symfony let any logged-in user do both. To match them, replace #[RequireRole(User::ROLE_ADMIN)] with #[RequireAuthenticated] on the write endpoints.
  • Validation and API documentation from one declaration. The OpenAPI attributes on the model and the endpoint validate the request (#[ValidateRequest], 400 on failure), validate the model again on every save() (so a command or a queue consumer is held to the same rules), and are the schema Swagger UI publishes, so the three cannot drift. codegen writes the types, the required fields and the lengths from the table (a NOT NULL text column without a default also gets minLength: 1, so an empty string is refused like a missing value); value ranges are yours.
  • Timestamps and soft delete from the column names. When the table has created_at, updated_at and deleted_at, codegen adds the OaCreatedAt, OaUpdatedAt and OaDeletedAt traits, and deleted rows are hidden. Laravel's equivalent is the SoftDeletes trait; Symfony and Doctrine have none of their own.
  • Lists that page and sort. Generated list endpoints take page, size and orderBy, and an orderBy that is not a field of the model is rejected with 400. The starter's frontend has a Pagination component for them.
  • A separate frontend, if you want one. An optional React SPA in frontend/ calls the API with the JWT, in its own container, with HTTPS in development (a self-signed certificate generated at start). Delete the folder and the API is unchanged.
  • Contract tests. The generated functional tests check every response against the OpenAPI document, and send invalid bodies to the server to check it answers 400.

Vocabulary​

LaravelSymfonyDrupalCakePHPCodeIgniterGluo
routes/api.php#[Route]*.routing.ymlconfig/routes.phpapp/Config/Routes.php#[OA\Get] + php gluo openapi
app/Modelssrc/EntityEntity APIsrc/Model/Entityapp/Models + app/Entitiessrc/Model + #[TableAttribute]
app/Http/Controllerssrc/ControllerController pluginsrc/Controllerapp/Controllerssrc/Controller
EloquentDoctrine ORMEntity/Field APICakePHP ORMModel + Query Builderbyjg/micro-orm
— (Eloquent is the repo)src/RepositoryEntityStoragesrc/Model/Table— (the Model is the repo)src/Repository
database/migrationsmigrations/hook_update_Nconfig/Migrationsapp/Database/Migrationsdb/migrations/{up,down}
config/*.phpconfig/services.yaml*.services.ymlconfig/app.phpapp/Config/*.phpconfig/{env}/*.php
.env.envsettings.phpconfig/app_local.php.envconfig/{env}/credentials.env
app()->make()autowiring\Drupal::service()Application::services()Config\Services / service()constructor injection (Autowire::rule() or explicit DI::bind(), your choice)
Starter kit login (Fortify)make:user + make:security:form-loginUser moduleAuthentication pluginShieldshipped LoginController (JWT)
Gate / Policy#[IsGranted]_permission route keyAuthorization policyShield filter#[RequireRole]
SoftDeletes traitGedmo extension (community) or your own filter——$useSoftDeletes on the ModelOaDeletedAt trait, added by codegen
Blade / Inertia pagesTwigTwig themesTemplatesViewsoptional React SPA in frontend/, over the API
— (community packages)— (NelmioApiDocBundle)———OpenAPI + Swagger UI, generated from the routes
FormRequestValidator + DTOForm APIvalidationDefault()$this->validate()#[ValidateRequest] + OpenAPI schema
php artisan tinker—drush phpbin/cake console—php gluo terminal

Commands​

A dash means the framework has no built-in command for it. Where Gluo has none, the cell says where the same thing lives instead.

TaskLaravelSymfonyCakePHPCodeIgniterGluo
Start the appphp artisan servesymfony servebin/cake serverphp spark servedocker compose up -d
Run the migrationsphp artisan migratephp bin/console doctrine:migrations:migratebin/cake migrations migratephp spark migratephp gluo --env=dev migrate:update
Migration statusphp artisan migrate:statusphp bin/console doctrine:migrations:statusbin/cake migrations statusphp spark migrate:statusphp gluo --env=dev migrate:version
Roll backphp artisan migrate:rollbackphp bin/console doctrine:migrations:migrate prevbin/cake migrations rollbackphp spark migrate:rollbackphp gluo --env=dev migrate:down --version=N
Rebuild the databasephp artisan migrate:freshphp bin/console doctrine:schema:drop --force—php spark migrate:refreshphp gluo --env=dev migrate:reset
New migrationphp artisan make:migrationphp bin/console make:migrationbin/cake bake migrationphp spark make:migration— write the SQL pair in db/migrations/up and down
Seed dataphp artisan db:seedphp bin/console doctrine:fixtures:loadbin/cake seeds runphp spark db:seed— db/base.sql, loaded by migrate:reset
Scaffold a CRUDphp artisan make:model -mcrphp bin/console make:crudbin/cake bake allphp spark make:scaffoldphp gluo --env=dev codegen:all --table=x --save
Generate one classphp artisan make:controllerphp bin/console make:controllerbin/cake bake controllerphp spark make:controllerphp gluo --env=dev codegen:controller --table=x
List the routesphp artisan route:listphp bin/console debug:routerbin/cake routesphp spark routes— Swagger UI at /docs, read from public/docs/openapi.json
Rebuild the routes—php bin/console cache:clear——php gluo openapi
Run the testsphp artisan testphp bin/phpunitvendor/bin/phpunitvendor/bin/phpunitphp gluo test
Interactive shellphp artisan tinker—bin/cake console (REPL plugin)—php gluo terminal
List the commandsphp artisan listphp bin/console listbin/cakephp spark listphp gluo list
New CLI commandphp artisan make:commandphp bin/console make:commandbin/cake bake commandphp spark make:command— add a method to src/Command/Commands.php
Scheduled jobsphp artisan schedule:run, from cronphp bin/console messenger:consume scheduler_default——php gluo --env=prod install --template=crond <command>
Background workerphp artisan queue:workphp bin/console messenger:consume async——php gluo --env=prod install <command> (a systemd service)
Static analysisPint / PHPStan— (PHPStan is common, but not configured)PHPStan—composer psalm

Only in Gluo​

CommandWhat it does
php gluo openapi:checkFails when the OpenAPI spec is not what the controllers and models generate, the cause of a silent 404. php gluo test runs it first.
php gluo list --checkGives every command, built-in and project, a status without running it: the target method exists, its class is known to the container, the database the migrations and codegen need is reachable.
php gluo --env=prod install <command>Installs a project command as a systemd, crond, upstart or initd service, with its command line written in full. The other frameworks give you the worker command and leave the service to you.
php gluo uninstall <command>Removes a service created by install.
php gluo codegen:all --table=xGenerates the model, repository, service, controller, functional test and DI bindings from an existing table. Only CakePHP's bake also reads the table.

Where you are already at home​

  • Layout. The repository root is the PHP application root — one composer.json, one vendor/, with src/, config/, db/, public/, tests/ beside it. See Repository layout.
  • Layering. Controller → Service → Repository → Model is the Symfony playbook. See Persistence above for the ActiveRecord alternative.
  • Authorisation attributes. #[RequireAuthenticated] and #[RequireRole('admin')] behave like Symfony's #[IsGranted].
  • Testing. Functional tests run against the OpenAPI contract, so a response that drifts from the documented schema fails the test. See Testing.

What is not included, and where to find it​

Gluo's core is deliberately small. There is no queue/job system, no event dispatcher, no scheduler, and no template layer for HTML. These live in the ByJG ecosystem as standalone components. They use the same conventions and plug into the same DI container, so you add one when you need it and nothing is wired in by default:

NeedComponentStatus
Queues and jobsbyjg/message-queue-client, with RabbitMQ and Redis connectorsadd it
Workers and cron jobsbyjg/scriptify installs a class method or an app command as a systemd or crond servicealready installed
HTML templatesbyjg/jinja-phpalready installed (renders the email templates)
Cachingbyjg/cache-engine (PSR-6 / PSR-16)already installed
Outbound HTTPbyjg/webrequest (PSR-18)add it
Feature flagsbyjg/featureflagadd it
Retries, circuit breakerbyjg/resilienceadd it
Workflowsbyjg/statemachineadd it

There is no generic event bus. The hooks that cover the usual reasons for one are already there:

  • After a record changes: MicroOrm observers fire on insert, update and delete of a table, whatever code path made the write.
  • Around every request: RestServer before/after middleware.
  • On a workflow step: a state machine TransitionActionInterface runs the side effects of a transition.
  • Across processes: publish a message with message-queue-client and consume it in a worker installed by scriptify.

See it side by side​

Gluo vs Laravel, Symfony, CakePHP and CodeIgniter builds the same small application with each of those frameworks (Drupal is not built there), each one following what its own framework recommends. It lists every command and every hand edit, so you can compare how each feature is actually done.

Next​

  1. Installation — create the project
  2. Your first table — migration then codegen
  3. Your first endpoint — attributes, and the regeneration step