Feature by feature
How each part of the application is done, with the code that is actually in this repository. For the commands that produced it, see the step-by-step guides: gluo.md, laravel.md, symfony-official.md and symfony.md.
Symfony is built twice. symfony-official/ uses official packages only;
symfony/ uses the community Docker template and a community soft-delete bundle. They
share the login, the generated CRUD, the validation, the routes, the screens and the
tests, so the Symfony column below describes both. Where they differ — creating the
project, timestamps and soft delete, Docker — the text says so, and
section 14 puts the two side by side.
- 1. Architecture
- 2. Creating the project
- 3. Login
- 4. Protecting the screens
- 5. Migration
- 6. Model
- 7. Timestamps and soft delete
- 8. CRUD controller
- 9. Validation
- 10. Routing
- 11. Screens
- 12. Tests
- 13. What comes in the box
- 14. The two Symfony builds
- 15. Project layout
1. Architecture
| Gluo | Laravel | Symfony | |
|---|---|---|---|
| Shape | REST API + separate SPA | one application | one application |
| Frontend | React 19 + Vite, own container | React 19 through Inertia, same app | Twig, server-rendered |
| Auth state | JWT in the browser, sent as Authorization: Bearer | session cookie | session cookie |
| Reusable from another client | yes, it is an API | no, the endpoints return Inertia pages | no, the endpoints return HTML |
| Containers | API, frontend, MySQL | app, MySQL | official: MySQL, mail catcher (PHP on the host). template: app, MySQL, mail catcher |
Gluo is the only one where the backend is a documented API that another client (mobile app, another service) could call as is.
2. Creating the project
| Command | Questions asked | |
|---|---|---|
| Gluo | composer create-project byjg/gluo gluo ^7.0 --stability=dev | namespace, database, keep frontend, keep examples — or all from setup.json |
| Laravel | composer create-project laravel/react-starter-kit laravel --stability=dev | which auth features to keep |
| Symfony (official) | composer create-project symfony/skeleton, composer require webapp (or symfony new --webapp) | whether Flex should write Docker configuration |
| Symfony (template) | git clone dunglas/symfony-docker, docker compose up, composer require webapp | none; the choices are the packages you require |
Friction found while building:
- Gluo —
--stability=devis required until 7.0 is tagged. - Laravel — the tagged starter kit is an old Laravel 12 one, so
--stability=devis required here too; the installer needs Node next to PHP. - Symfony (official) — no problem creating the project. The database service Flex writes is PostgreSQL, so MySQL is a three-file manual switch.
- Symfony (template) — the Docker template did not start with the current FrankenPHP image until one Mercure setting was changed, and it also defaults to PostgreSQL: a four-file manual switch.
3. Login
| Gluo | Laravel | Symfony | |
|---|---|---|---|
| Where it comes from | shipped with the starter | shipped with the starter kit | generated by two maker commands |
| Mechanism | POST /login returns a JWT | Fortify session login | form_login authenticator |
| Code you write | none | none | none |
| Also included | token refresh, password reset by e-mail code, roles | registration, e-mail verification, password reset, 2FA, passkeys | logout |
Gluo — src/Controller/LoginController.php is a thin class over the core; the route,
the request schema and the validation are attributes:
#[OA\Post(path: "/login", tags: ["Login"])]
#[OA\RequestBody(...)] // username + password
#[ValidateRequest]
public function post(HttpResponse $response, HttpRequest $request): void
{
parent::post($response, $request);
}
Laravel — there is no login controller in the project; Fortify owns the routes and
the project only says which page renders the form (app/Providers/FortifyServiceProvider.php):
Fortify::loginView(fn (Request $request) => Inertia::render('auth/login', [...]));
Symfony — make:security:form-login writes a controller that only shows the form;
the firewall does the authentication (config/packages/security.yaml):
firewalls:
main:
provider: app_user_provider
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
default_target_path: app_product_index
logout:
path: app_logout
4. Protecting the screens
Gluo — per endpoint, as an attribute, with roles available out of the box:
#[RequireAuthenticated] // any valid token: get, list
#[RequireRole(User::ROLE_ADMIN)] // admin only: create, update, delete
On the SPA side the route sits inside <ProtectedLayout>, which redirects to /login
when there is no valid token.
Laravel — per route group, in routes/web.php:
Route::middleware(['auth', 'verified'])->group(function () {
Route::resource('products', ProductController::class)->except('show');
});
Symfony — by URL pattern, in config/packages/security.yaml:
access_control:
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_USER }
Gluo is the only build where reading and writing have different permissions without
extra work; the other two would add a policy (Laravel) or #[IsGranted] (Symfony).
5. Migration
| Gluo | Laravel | Symfony | |
|---|---|---|---|
| Written in | SQL | PHP schema builder | SQL, generated |
| Who writes it | you | you | make:migration, by diffing entities against the database |
| Rollback | a second SQL file you write | down() you write (generated for a new table) | down() generated |
| Date columns | in the SQL you write | timestamps() + softDeletes() | from the entity fields (official: generated by make:entity; template: from the bundle's traits) |
| Run | ./gluo --env=dev migrate:update | artisan migrate | doctrine:migrations:migrate |
Gluo — db/migrations/up/00001-create-table-product.sql:
create table product
(
id int auto_increment not null primary key,
name varchar(100) not null,
price decimal(10,2) not null,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL
) ENGINE=InnoDB;
Laravel — database/migrations/*_create_products_table.php:
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name', 100);
$table->decimal('price', 10, 2);
$table->timestamps();
$table->softDeletes();
});
Symfony — nothing is written; the generated migration contains:
$this->addSql('CREATE TABLE product (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(100) NOT NULL,
price NUMERIC(10, 2) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL,
deleted_at DATETIME DEFAULT NULL, PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
The source of truth differs: the table in Gluo, the migration in Laravel, the entity class in Symfony.
6. Model
| Gluo | Laravel | Symfony | |
|---|---|---|---|
| ORM | MicroOrm | Eloquent | Doctrine ORM |
| Pattern used here | ActiveRecord | ActiveRecord | Data Mapper + Repository |
| Fields declared in the class | yes, generated | no, read from the table at runtime | yes, generated |
| Accessors | generated | magic properties | generated |
| File size | 118 lines, 1 edited by hand | 16 lines, 2 written by hand | official: 114 lines, ~15 by hand. template: 63 lines, 7 by hand |
| Type and length of each field | yes: OA\Property(type, format, maxLength), generated from the column | no; they are in the migration | yes: ORM\Column(length, precision, scale), generated |
| Allowed range of a value | yes: OA\Property(minimum, maximum), by hand | no; it is in the FormRequest | yes: Assert\Range, by hand |
| Required fields | yes: OA\Schema(required: [...]), generated from NOT NULL | no; required in the FormRequest | yes: Assert\NotBlank, by hand |
| The same declaration validates requests | yes | — | yes (through the form) |
| The same declaration is published to API clients | yes, as the OpenAPI schema | — | no |
Model patterns each one supports
| Gluo (MicroOrm) | Laravel (Eloquent) | Symfony (Doctrine) | |
|---|---|---|---|
| ActiveRecord | yes: use ActiveRecord on the model, then Product::get($id), $product->save() | yes, and it is the only built-in pattern | no |
| Repository | yes: Model + Repository + Service, bound in the DI config | not built in; you write the layer yourself on top of Eloquent | yes, with Data Mapper: plain entities, the EntityManager persists them, one repository class per entity for queries |
| Choice per entity | yes, both can be used in the same project | — | — |
| Generator | both: ./gluo codegen:all writes the Repository pattern, --activerecord the other | make:model (ActiveRecord) | make:entity writes the entity and its repository |
| Layers in a generated CRUD | ActiveRecord: Controller -> Model. Repository: Controller -> Service -> Repository -> Model | Controller -> Model | Controller -> EntityManager / Repository -> Entity |
| Files for one entity | ActiveRecord: 2. Repository: 4, plus 2 DI bindings | 1 model (+ controller) | 2 (entity, repository) (+ controller) |
- ActiveRecord — the object saves and loads itself. Least code, and the model is tied to the database, which is fine for plain CRUD.
- Repository — the model is data only; a separate class talks to the database, and in Gluo a service holds the business rules between the controller and the repository. More files, easier to test and to grow.
- Data Mapper — Doctrine's version of the same separation: the entity knows nothing
about persistence, and the
EntityManagertracks changes and writes them onflush().
Gluo is the only one of the three where this is a choice, per entity, with a generator for both. Laravel gives you ActiveRecord and Symfony gives you Data Mapper; moving away from either means writing the other pattern by hand.
In Gluo the choice is made when the code is generated, not in a configuration setting: the two patterns produce a different model and a different controller. The endpoints and the generated test are the same in both, so the API does not change. Switching an entity later is a regeneration:
# ActiveRecord -> Repository: add the two layers and replace the controller
./gluo --env=dev codegen:repository --table=product --save
./gluo --env=dev codegen:service --table=product --save
./gluo --env=dev codegen:controller --table=product --save
# Repository -> ActiveRecord: replace the model and the controller,
# then delete the repository, the service and their DI bindings
./gluo --env=dev codegen:model --table=product --activerecord --save
./gluo --env=dev codegen:controller --table=product --activerecord --save
--save overwrites the controller, so hand edits to it (here, the delete endpoint and
the price range) have to be applied again. Without --save the code is printed instead of
written, which is a way to look at the other pattern before deciding.
This comparison uses ActiveRecord for Gluo because it is the smallest version of the same feature.
The model in each project
Gluo — src/Model/Product.php. Each field carries two attributes: FieldAttribute
maps it to the column, and OA\Property declares its type, length and range. Everything
is as codegen wrote it, including maxLength: 100 taken from varchar(100), except
the price range, which was added by hand:
#[OA\Schema(required: ["id", "name", "price"], type: "object", xml: new OA\Xml(name: "Product"))]
#[TableAttribute("product")]
class Product
{
use ActiveRecord;
use OaCreatedAt;
use OaUpdatedAt;
use OaDeletedAt;
#[OA\Property(type: "integer", format: "int32")]
#[FieldAttribute(primaryKey: true, fieldName: "id")]
protected int|null $id = null;
#[OA\Property(type: "string", format: "string", maxLength: 100)]
#[FieldAttribute(fieldName: "name")]
protected string|null $name = null;
#[OA\Property(type: "number", format: "double", minimum: 0, maximum: 99999999.99)]
#[FieldAttribute(fieldName: "price")]
protected float|null $price = null;
// + getters and setters
}
Laravel — app/Models/Product.php, complete:
#[Fillable(['name', 'price'])]
class Product extends Model
{
use HasFactory, SoftDeletes;
}
Symfony — src/Entity/Product.php, template build (the official build declares the
three date fields itself instead of the two traits; see section 7):
#[ORM\Entity(repositoryClass: ProductRepository::class)]
#[Gedmo\SoftDeleteable]
class Product
{
use TimestampableEntity;
use SoftDeleteableEntity;
#[ORM\Column(length: 100)]
#[Assert\NotBlank]
#[Assert\Length(max: 100)]
private ?string $name = null;
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2)]
#[Assert\NotBlank]
#[Assert\Range(min: 0, max: 99999999.99)]
private ?string $price = null;
// + id, getters and setters
}
The Gluo model and the Symfony entity carry the same kind of information: the type and
length of each field, which fields are required, and the range a value may take. In
both, the length comes from the generator and the range is written by hand. They differ
in what the declaration is used for. Symfony's ORM\Column drives the SQL of the
migration and Assert\* drives form validation. Gluo's OA\Property drives request
validation and is also the schema published in the OpenAPI document; the column's
precision and scale stay in the SQL migration. Laravel's model declares none of it: the
types are in the migration and the rules in the FormRequest.
7. Timestamps and soft delete
| Gluo | Laravel | Symfony (official) | Symfony (template) | |
|---|---|---|---|---|
| Built in | yes | yes | timestamps: lifecycle callbacks. Soft delete: no | no — stof/doctrine-extensions-bundle (community) |
| Turned on by | codegen, when it sees the three columns | timestamps() + softDeletes() in the migration, SoftDeletes trait | 3 generated fields, 2 callbacks, 1 line in the delete action, a 21-line SQL filter, 1 YAML block | 2 traits + 1 attribute on the entity, 2 YAML blocks |
| Who sets the dates | the database (DEFAULT / ON UPDATE) and the trait | Eloquent | your callbacks | the bundle's listener |
| Deleted rows hidden | automatically | automatically | by the filter you wrote | once the bundle's filter is enabled |
| Code you own | none | none | all of it | none |
Gluo and Laravel need nothing beyond naming the columns. Symfony and Doctrine have no soft delete, so there are two ways to get it.
Official packages only — symfony-official/. The dates use Doctrine lifecycle
callbacks, and deleting becomes "set the date" plus a Doctrine SQL filter:
#[ORM\HasLifecycleCallbacks]
class Product
{
#[ORM\PrePersist]
public function onCreate(): void
{
$this->createdAt = $this->updatedAt = new \DateTimeImmutable();
}
#[ORM\PreUpdate]
public function onUpdate(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
}
// src/Controller/ProductController.php, in the generated delete()
$product->setDeletedAt(new \DateTimeImmutable());
// src/Doctrine/SoftDeleteFilter.php
class SoftDeleteFilter extends SQLFilter
{
public function addFilterConstraint(ClassMetadata $targetEntity, string $targetTableAlias): string
{
if (!$targetEntity->hasField('deletedAt')) {
return '';
}
return sprintf('%s.%s IS NULL', $targetTableAlias, $targetEntity->getColumnName('deletedAt'));
}
}
Community bundle — symfony/. Two traits and an attribute on the entity (shown in
6. Model) and this configuration:
# config/packages/stof_doctrine_extensions.yaml
stof_doctrine_extensions:
orm:
default:
timestampable: true
softdeleteable: true
# config/packages/doctrine.yaml, under doctrine.orm
filters:
softdeleteable:
class: Gedmo\SoftDeleteable\Filter\SoftDeleteableFilter
enabled: true
The bundle is less code and is what most Symfony projects use; the official-only route adds about 35 lines you maintain yourself. Both pass the same soft-delete test.
8. CRUD controller
| Gluo | Laravel | Symfony | |
|---|---|---|---|
| Generated | get, list, create, update — working | method signatures only | index, new, show, edit, delete — working |
| Written by hand | delete | all six bodies | nothing (official build: 1 line changed in delete) |
| Returns | JSON | Inertia page or redirect | Twig page or redirect |
| Paging and ordering on list | yes, generated (page, size, orderBy) | no | no |
Gluo — src/Controller/ProductController.php (OpenAPI attributes omitted):
#[RequireAuthenticated]
public function listProduct(HttpResponse $response, HttpRequest $request): void
{
$page = (int)($request->queryString('page') ?? 0);
$size = (int)($request->queryString('size') ?? 50);
$query = Query::getInstance()
->table(Product::tableName())
->orderBy(OrderBy::fromQueryString($request->queryString('orderBy'), Product::class))
->limit($page * $size, $size);
$response->write(Product::query($query));
}
#[RequireRole(User::ROLE_ADMIN)]
#[ValidateRequest]
public function postProduct(HttpResponse $response, HttpRequest $request): void
{
$model = Product::new(ValidateRequest::getPayload());
$model->save();
$response->write(["id" => $model->getId()]);
}
Laravel — app/Http/Controllers/ProductController.php:
public function index(): Response
{
return Inertia::render('products/index', [
'products' => Product::latest('id')->get(),
]);
}
public function store(ProductRequest $request): RedirectResponse
{
Product::create($request->validated());
return to_route('products.index');
}
Symfony — src/Controller/ProductController.php, as make:crud wrote it:
#[Route('/new', name: 'app_product_new', methods: ['GET', 'POST'])]
public function new(Request $request, EntityManagerInterface $entityManager): Response
{
$product = new Product();
$form = $this->createForm(ProductType::class, $product);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->persist($product);
$entityManager->flush();
return $this->redirectToRoute('app_product_index', [], Response::HTTP_SEE_OTHER);
}
return $this->render('product/new.html.twig', ['product' => $product, 'form' => $form]);
}
The Gluo controller is the longest file of the three (about 300 lines), because every endpoint carries its OpenAPI description. That description is not decoration: it is the route, the request validation and the published documentation at once.
The generated list endpoint also sorts: ?orderBy=price desc,name. The value comes from
the client, so it is checked against the fields of the model before it is used, and
anything else — an unknown field, or an attempt at SQL — is answered with 400. There is
no generated filter parameter.
9. Validation
| Gluo | Laravel | Symfony | |
|---|---|---|---|
| Declared on | the model properties and the endpoint's request body, as OpenAPI attributes | a FormRequest class | the entity properties |
| Enforced by | #[ValidateRequest] | type-hinting the request in the action | the form component |
| Written by | codegen: types, required fields and lengths, from the table. You: value ranges | you | you |
| Failure | 400 JSON | redirect back with errors (422 for JSON requests) | 422, form re-rendered |
Gluo — generated on postProduct:
#[OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ["name", "price"],
properties: [
new OA\Property(property: "name", type: "string", format: "string", maxLength: 100),
new OA\Property(property: "price", type: "number", format: "double", minimum: 0, maximum: 99999999.99),
]
)
)]
Everything in it is generated except the price range. putProduct validates against the
model's schema (#/components/schemas/Product), shown in 6. Model, so the
same limits are declared there.
Laravel — app/Http/Requests/ProductRequest.php:
return [
'name' => ['required', 'string', 'max:100'],
'price' => ['required', 'numeric', 'min:0', 'max:99999999.99'],
];
Symfony — the #[Assert\...] attributes shown in 6. Model.
All three end up enforcing the same rules: a name of at most 100 characters, and a price between 0 and 99999999.99, both required.
Gluo takes the types, the required fields and maxLength from the table when it
generates the code. A range cannot be read from a column type, so minimum and maximum
are added by hand, the same as min/max in Laravel and Assert\Range in Symfony.
Tried against the running API, on both POST and PUT: a 101-character name, a
negative price, a price above the range and a missing name are each rejected with 400,
before anything reaches the database.
The same schema is what Swagger UI publishes, so the documented limits and the enforced ones cannot drift apart.
10. Routing
| Gluo | Laravel | Symfony | |
|---|---|---|---|
| Declared in | #[OA\Get(path: ...)] on the method | routes/web.php | #[Route] on the method |
| Extra step | ./gluo openapi rebuilds public/docs/openapi.json | none | none |
| One line for the whole CRUD | no | Route::resource(...) | no (generated per action) |
In Gluo the route table is the generated openapi.json. Forgetting ./gluo openapi
after changing an attribute gives a 404 with no error; composer test warns when the
file is stale.
11. Screens
| Gluo | Laravel | Symfony | |
|---|---|---|---|
| Technology | React page calling the API | React page receiving props from the controller | Twig templates |
| Generated | no | no | yes: list, show, new, edit, delete form |
| Written by hand | 1 page, 139 lines, plus route and menu entry | 2 pages, 139 lines, plus type and menu entry | nothing |
| Styling | Tailwind components shipped with the starter | shadcn/ui components shipped with the kit | none; plain HTML |
| Typed links to the backend | no, paths are strings | yes, Wayfinder generates them | Twig path() by route name |
Symfony is the only one where the screens cost nothing, and the only one where they are unstyled and have no menu or logout link.
12. Tests
| Gluo | Laravel | Symfony | |
|---|---|---|---|
| Generated for the CRUD | 9 tests, passing | an empty test class | 5 tests, 4 marked incomplete, with invalid data |
| Written by hand here | 2 (soft delete; four invalid payloads) | 7 | 7, replacing the generated ones |
| Style | in-process API calls | HTTP feature tests + Inertia assertions | browser-kit crawler over the HTML |
| Checks the response shape | yes, against the OpenAPI contract | only what each test asserts | only what each test asserts |
| Database | a separate localtest schema | testing database, refreshed per test | app_test database |
Gluo:
$request = new FakeApiRequester();
$request
->withPsr7Request($this->getPsr7Request())
->withMethod('POST')
->withPath("/product")
->withRequestBody(json_encode($this->getSampleData(true)))
->expectStatus(200)
->withRequestHeader(["Authorization" => "Bearer " . $result['token']]);
$body = $this->sendRequest($request);
Laravel:
$this->actingAs(User::factory()->create());
$this->post(route('products.store'), ['name' => 'Keyboard', 'price' => 49.9])
->assertRedirect(route('products.index'));
$this->assertDatabaseHas('products', ['name' => 'Keyboard', 'price' => 49.90]);
Symfony:
$this->login();
$this->client->request('GET', '/product/new');
$this->client->submitForm('Save', ['product[name]' => 'Keyboard', 'product[price]' => '49.90']);
self::assertResponseRedirects('/product');
13. What comes in the box
Beyond what this application needed.
| Gluo | Laravel | Symfony | |
|---|---|---|---|
| OpenAPI spec + Swagger UI | yes | no | no |
| Command line for framework tasks and your own commands | ./gluo | artisan | bin/console |
| Role-based access | yes | gates/policies, not wired | roles, not wired |
| Timestamps and soft delete | yes | yes | no (callbacks + your own filter, or a community bundle) |
| HTTPS in the development setup | frontend only: self-signed certificate, generated at start, on :7443 | no; Sail serves plain HTTP | official: no with php -S. template: yes, for the whole app |
| Docker setup from the framework itself | yes, full stack | yes, full stack (Sail) | services only (Flex); the full-stack template is a community project |
| Password reset | yes | yes | no |
| Registration, e-mail verification, 2FA, passkeys | no | yes | no |
| Profile screen | yes | yes | no |
| Queue, scheduler, events | no | yes | yes (Messenger) |
| Debug toolbar / profiler | no | no | yes |
| Static analysis configured | Psalm | Pint (style), Larastan | no |
| Composer packages (prod + dev) | 56 + 25 | 100 + 38 | official: 96 + 35. template: 98 + 35 |
HTTPS in development
- Gluo — the frontend container serves HTTPS as well as HTTP. Its web server,
byjg/static-httpserver, creates a self-signed certificate when it starts and listens on 8443, published as https://localhost:7443; mountingcert.pemandkey.pemat/certsreplaces it with a real one. The API container is plain HTTP on :8080, and that is the address the frontend calls. - Laravel — Sail serves plain HTTP. This build runs on http://localhost:8081 and nothing was added for HTTPS.
- Symfony, template — FrankenPHP (Caddy) serves the whole application over HTTPS by
default, with certificates from its own local certificate authority. This build turns
that off (
SERVER_NAME=:80) to keep plain HTTP on :8082 next to the other projects. - Symfony, official — PHP's built-in server, used here, has no HTTPS. Symfony's
documentation describes TLS support in
symfony server:start, which was not used in this build.
So two of the setups come with HTTPS without any work: Gluo for the frontend, and the Symfony Docker template for the whole application.
14. The two Symfony builds
Laravel's starter kit, Sail and Eloquent's soft delete are all first-party, and Gluo ships its own Docker files and soft-delete trait, so one build of each is "official" by construction. Symfony is the one where following the documentation leads outside the framework, so it is built both ways.
symfony-official/ | symfony/ | |
|---|---|---|
| Created with | composer create-project symfony/skeleton + composer require webapp | git clone dunglas/symfony-docker, which runs the same skeleton inside the container |
Packages outside symfony/*, Doctrine and Twig | none | stof/doctrine-extensions-bundle (Gedmo) |
| Docker | the service file Flex writes: MySQL and a mail catcher | community template: FrankenPHP app container, MySQL, mail catcher |
| Where PHP runs | on the host (symfony server:start, or php -S as used here) | in the container |
| Web server | PHP built-in server (dev only) | FrankenPHP / Caddy, also usable in production |
| HTTPS in development | no | yes by default (turned off in this build) |
| Timestamps | 2 lifecycle callbacks, written by hand | bundle trait |
| Soft delete | 1 line in the controller + a 21-line SQL filter, written by hand | bundle trait + attribute |
| MySQL instead of the default PostgreSQL | 3 files | 4 files (also the Dockerfile) |
| Fixes needed to start | none | 1 (Mercure setting for the current FrankenPHP image) |
| Files owned by root on Linux | no | yes, needs a chown after generating files |
| Composer packages (prod + dev) | 96 + 35 | 98 + 35 |
| Hand-written lines for the feature | ~45 | ~20 |
| Tests | 8, the same file | 8, the same file |
What is identical: the login (make:user, make:security:form-login), the entity
fields for name and price, make:crud and everything it generates, the validation
attributes, access control, the fixtures and the tests.
What the choice costs: the official-only build is simpler to start and has nothing to fix, but you write and maintain the soft delete yourself and you need PHP on the machine. The template build gives a complete, production-capable container and less application code, in exchange for depending on two community projects, one of which did not start without a change.
15. Project layout
The first-level folders and files of each project, lined up by what they are for. The Symfony column is the same for both builds unless a cell says otherwise.
| Purpose | Gluo | Laravel | Symfony | Notes |
|---|---|---|---|---|
| Application code | src/ | app/ | src/ | Gluo: Model, Controller (plus Repository, Service in that pattern). Laravel: Models, Http/Controllers, Http/Requests, Providers, Actions. Symfony: Entity, Controller, Repository, Form. |
| Database migrations | db/ | database/migrations/ | migrations/ | All three version the schema, in three different languages. Gluo: plain .sql files, one in up/ and one in down/, plus base.sql for the starting schema. Laravel: PHP classes that call the schema builder ($table->string(...)), no SQL. Symfony: PHP classes whose body is SQL strings, generated by comparing the entities with the database. |
| Seed and test data | db/base.sql, insert in a migration | database/seeders/, database/factories/ | src/DataFixtures/ | Gluo seeds in SQL. Laravel and Symfony seed from PHP; Laravel's factories also feed the tests. |
| Configuration | config/ | config/, .env | config/, .env, .env.dev, .env.test | Gluo: one folder per environment (dev, test, staging, prod), where every *.php and every *.env file is loaded (the starter ships numbered PHP files and a credentials.env); environments inherit from each other. config/.env is an optional, git-ignored file for overrides on your own machine, with config/.env.sample as its template. Everything stays inside config/. Laravel: one PHP file per subject, values from a single .env at the project root. Symfony: YAML, one file per bundle under config/packages/, values from .env files at the project root, one per environment. |
| Wiring the container | config/<env>/04-…07-*.php | bootstrap/, app/Providers/ | config/services.yaml, src/Kernel.php | All three build a class by reading the type hints of its constructor. They differ in who decides what the container may build. Gluo: the developer. Autowire::rule() autowires every class of a namespace you name (the starter does it for the controllers, in 07-controllers.php), and a repository or service is bound one by one with withInjectedConstructor(), where you also choose singleton or per-use. A class that no rule or binding covers is not built. Laravel: the container builds any concrete class on request, with no registration. Symfony: services.yaml registers and autowires everything under src/. |
| Routes | — | routes/ | — | Laravel is the only one with route files. Gluo and Symfony declare routes as attributes on the controller; Gluo then compiles them into public/docs/openapi.json. |
| Web root | public/ | public/ | public/ | The front controller is app.php in Gluo and index.php in the others. Gluo's also holds the generated OpenAPI spec and Swagger UI; Laravel's receives the built frontend in public/build/. |
| Screens | frontend/ | resources/ | templates/, assets/ | Gluo: a separate package with its own package.json, src/ and Docker image. Laravel: React pages in resources/js/, with package.json, vite.config.ts and node_modules/ at the project root. Symfony: Twig templates, with JavaScript in assets/ served through importmap.php, without Node. |
| Other templates | templates/ | resources/views/ | templates/ | Gluo: e-mail templates and the terminal preload. Laravel: the single Blade page that hosts the React app. |
| Tests | tests/ | tests/ | tests/ | PHPUnit in all three. |
| Command line | gluo | artisan | bin/ | ./gluo, php artisan, bin/console. Symfony's bin/ also has phpunit. |
| Project hooks for the framework tasks | builder/ | app/Console/, routes/console.php | — | Gluo's builder/Scripts.php is where the built-in commands are customised; your own commands are listed in config/commands.php. Laravel and Symfony register commands as classes. |
| Cache, logs, sessions | — | storage/, bootstrap/cache/ | var/ | Gluo keeps no runtime folder in the project: the configuration cache goes to the system temp directory. |
| Docker | docker/, docker-compose.yml | compose.yaml | compose.yaml, compose.override.yaml | Gluo ships its Dockerfiles in the project. Laravel's images live inside vendor/laravel/sail. Symfony official: service definitions only. Symfony template adds Dockerfile, frankenphp/, compose.prod.yaml and .devcontainer/. |
| Translations | — | — | translations/ | Created empty by Symfony. Laravel publishes lang/ only on request. |
| Dependencies | vendor/, frontend/node_modules/ | vendor/, node_modules/ | vendor/ | |
| Static analysis and style | psalm.xml | phpstan.neon, pint.json | — | |
| Instructions for AI coding agents | .claude/ | — | AGENTS.md, CLAUDE.md | Gluo ships a development skill; Symfony ships conventions. |
Things that stand out:
- Where the schema lives. Gluo's
db/is the only folder a database administrator could read and run without PHP. Laravel's migrations cannot be read as SQL at all, and Symfony's are SQL you did not write. - Where the routes live. Only Laravel has a place to look them up as a list. In
Gluo the list is the generated OpenAPI file (and Swagger UI); in Symfony it is
bin/console debug:router. - Who controls autowiring. All three inject by type hint. In Laravel and Symfony it is on for everything; in Gluo it is on where you turn it on, by a pattern rule or a binding, so the config files list exactly what the container can build.
- How separate the frontend is. Gluo's
frontend/can be deleted, or deployed elsewhere, without touching the API. Laravel's and Symfony's screens are part of the application. - How many folders you start with. Counting folders at the first level, without
dependencies and hidden ones: Gluo 9, Laravel 9, Symfony official 10, Symfony template
12 (two of those are the template's own
docs/andfrankenphp/).
In one paragraph each
Gluo generated the most working backend from the least input: one SQL file became a model, four endpoints, their documentation and passing tests. What it left to write was the delete endpoint, the price range and the whole screen, and it asks you to remember one extra command after touching a route.
Laravel generated the least for the feature itself — every body and every screen was written by hand — but what you write is short, and the starter kit ships by far the most complete authentication and UI.
Symfony generated the whole CRUD including the screens and the SQL, at the price of the most setup: access control by hand, unstyled pages, placeholder tests, and no soft delete of its own — either hand-written with official packages only, or from a community bundle on a community Docker template that needed fixing.