Changelog - Version 7.0
Status: in development. This document tracks changes landing on the
7.0branch. Nothing here is released yet, and the contents may still change.
Breaking Changes
-
BaseUserhashes passwords with bcrypt (byjg/authuser'sPasswordBcryptMapper) instead of unsalted SHA-1. Widen theusers.passwordcolumn toVARCHAR(255)(a bcrypt hash has 60 characters; a new project gets it from the starter'sdb/base.sql, an existing one adds a migration:ALTER TABLE users MODIFY password VARCHAR(255) NOT NULL;). Existing users keep logging in: a SHA-1 hash still verifies, and the login (createAuthToken()) replaces it with a bcrypt hash; until the column is widened, the SHA-1 hash is kept. To stay on SHA-1, redeclare$passwordin yourUsermodel with#[FieldAttribute(updateFunction: PasswordSha1Mapper::class)]. -
BaseUserhas no constructor any more: it fetched thePasswordDefinitionwithConfig::get(), so creating a user needed the container. The definition reachesUsersServicethrough DI, and byjg/authuser 7.0 checks it on every save of the users repository (PasswordDefinitionValidator) and applies it to the users the service creates or loads. Anew User(...)no longer validates its password insetPassword(); it is checked when it is saved. -
The reset-flow constants of
BaseUserare renamed:PROP_RESETTOKEN→PROP_RESET_TOKEN,PROP_RESETTOKENEXPIRE→PROP_RESET_TOKEN_EXPIRE,PROP_RESETCODE→PROP_RESET_CODE,PROP_RESETALLOWED→PROP_RESET_ALLOWED. Their values (the property names stored in the database) are the same. -
The routes are bound as
RouteListInterface::classinstead ofOpenApiRouteList::class: theApplicationandFakeApiRequester(the API tests) get them by that key. Rename the key inconfig/dev/03-api.php; the binding itself stays the same:RouteListInterface::class => DI::bind(OpenApiRouteList::class)->withConstructorArgs([__DIR__ . '/../../public/docs/openapi.json'])// ...->toSingleton(), -
BaseConfigBootstrapno longer caches the resolved container forstagingandprod. It usedFileSystemCacheEnginein the system temp dir, which only works when that dir is thrown away on every deploy (e.g. a fresh container); on a cache that outlives the deploy (a shared memcached/Redis, a persistent volume) the new release kept loading the previous release's container. Where the cache lives now is the project's decision, inconfig/ConfigBootstrap.php; the starter template already does it. To keep the previous behaviour in an existing project:return new class extends BaseConfigBootstrap {#[\Override]protected function configureDefinition(Definition $definition): void{parent::configureDefinition($definition);$definition->getConfigObject('staging')->withCache(new FileSystemCacheEngine());$definition->getConfigObject('prod')->withCache(new FileSystemCacheEngine());}}; -
Generated tests are now in the
Tests\Controllernamespace (it wasTest\Controller), to match the other ByJG components. A project created before this change mapsTest\totests/in itscomposer.json: either rename that namespace toTests\(theautoload-deventry and thenamespace/uselines undertests/), or adjust the namespace of each newly generated test by hand. -
The generated
PUTtakes the key from the path:PUT /product/{id}, asGETandDELETEalready did, instead ofPUT /productwith the key in the body. Its body is the same as thePOST's: the writable fields only. A key sent in the body of either is now an unknown property, and the request is answered with 400. To move an existing controller, give itsOA\Putthe path/{resource}/{id}and anOA\Parameterforid(as itsOA\Gethas), use theOA\RequestBodyof itsOA\Post, and read the key with$request->attribute('id'). The clients change fromPUT /producttoPUT /product/{id}. -
BaseService::update(array $payload)is nowupdate(mixed $id, array $payload): the record is the one$idnames, as ingetOrFail($id), and a key in the payload is ignored instead of selecting the record. Before, a payload without the key failed with 422. -
BaseService's 404 says which model was not found:"Product not found", as the ActiveRecord trait'sgetOrFail()does. It was"Id not found". A client or a test that matches the old message has to change, or pass the message it wants togetOrFail(),update()ordelete(). -
BaseService::delete($id)answers 404 when there is no record with that key. It used to succeed without deleting anything, unlike the ActiveRecord controllers, which already looked the record up first.
New Features
-
php gluo openapialso compilesopenapi.jsonintogenerated/:routes.php, the routes and their dispatch data (byjg/restserver'sCompiledRouteList), andopenapi.php, the spec as a PHP array (byjg/swagger-test'sSchema::fromFile()reads it). OPcache keeps both in memory: a web request no longer parsesopenapi.jsonfor the routes (~390 us) nor for the request validation (~160 us), nor builds the dispatcher.openapi:check(andgluo test) reports a missing or out-of-date compiled file. The routes are compiled fromgetOpenApiRouteList()(the JSON-clean output processor by default) and written togetGeneratedPath(): override them in the project'sScripts. -
ByJG\Gluo\Http\Application: the HTTP entry point of a Gluo application, whatpublic/app.phpdid itself. It gets theServerand theOpenApiRouteListfrom the container, andhandle()serves one request:handle()alone handles the request PHP's globals describe, as before, andhandle($psr7Request)a PSR-7 request (byjg/restserver'sServer::withRequestObject()), rendering RestServer's HTTP errors itself. A persistent worker (RoadRunner, Swoole, a CLI worker) gets theApplicationonce and callshandle()for each request. A project binds it inconfig/dev/03-api.php:Application::class => DI::bind(Application::class)->withInjectedConstructor()->toSingleton(),and
public/app.phpbecomesConfig::get(Application::class)->handle();(the starter does both). -
ByJG\Gluo\Trait\ActiveRecord: MicroOrm'sActiveRecordplusgetOrFail($pk, ?string $message = null), which returns the record or throwsError404Exception("<Model> not found"by default, or the message given). It is the ActiveRecord counterpart ofBaseService::getOrFail(), like Laravel'sfindOrFail(), so a controller does not write the null check before it shows, changes or deletes a record. codegen uses it in ActiveRecord models, and the generated controllers callModel::getOrFail($request->attribute('id')). An existing model switches by importingByJG\Gluo\Trait\ActiveRecordinstead ofByJG\MicroOrm\Trait\ActiveRecord. -
BaseService::getOrFail(),update()anddelete()take the message of their 404 as an optional last argument:getOrFail($id, $message),update($id, $payload, $message),delete($id, $message). -
ByJG\Gluo\Util\SchemaValidator: a model is validated against its own OpenAPI schema every time it is saved, not only when it comes in through an endpoint with#[ValidateRequest]. A command, a queue consumer or a service that saves a model is held to the same rules: the types, lengths, ranges and required fields of itsOA\Propertyattributes. It is a MicroOrm validator, declared in the table attribute:#[TableAttribute("product", validator: SchemaValidator::class)]The model is serialized as the API returns it and matched against
#/components/schemas/<Model>(byjg/swagger-test'sSchema::matchDefinition()). AreadOnlyproperty, such as a generated key, is not required, and a null property is treated as not set. A mismatch throws the same 400 as#[ValidateRequest]. Writes that do not go throughsave()(UpdateQuery,InsertBulkQuery, raw SQL) are not validated. codegen declares it on every generated model. -
codegen marks the generated values
readOnlyin the model's schema: the primary key (an auto-increment column, or a UUID seeded byTableUuidPKAttribute) and the virtual columns.OaCreatedAt,OaUpdatedAtandOaDeletedAtmark their datesreadOnlytoo. The API returns them and does not take them. -
ByJG\Gluo\Console\Launcher: thephp gluocommand line entry point.Built-in commands replace the Composer scripts and run through the project's
Builder\Scripts, so project overrides still apply:Command Was php gluo migrate:update(create,version,reset,up,down)composer migrate -- updatephp gluo codegen:all --table=x(model,repository,service,controller,test)composer codegen -- --table=x allphp gluo openapi,php gluo openapi:checkcomposer openapi,composer openapi:checkphp gluo terminalcomposer terminalphp gluo test(openapi:check, then PHPUnit with the arguments passed through)composer testProject commands come from a
ByJG\Scriptify\CommandProviderclass of the project, which thegluoscript passes to the launcher:new Launcher(__DIR__, new \App\Command\Commands()). Itscommands()maps a name to a target with class references:'user:cleanup' => Maintenance::class . '::cleanOldRecords', or a class for every public method it declares, in kebab-case ('payment' => PaymentCommands::classgivespayment:create,payment:clean-old-records, ...). A typo fails in the IDE and in Psalm, and a rename is refactor-safe. Arguments are positional and map to the method parameters;php gluo user:cleanup --helpprints the method's PHPDoc, andphp gluo listits first line.The registry, the class expansion, the name rules, the help and the project rows of
list --checkare Scriptify'sLauncher;ByJG\Gluo\Console\Launcherextends it with the built-ins,--envand the database rows, and reserves the built-in names. The class of a project command is built by the Gluo container (Config::getContainer()), only when a project command runs orlist --checkaskshas():php gluo listand the built-ins never build it. -
php gluo --env=<env> install [--template=systemd|crond|upstart|initd] <command> [args...]installs a project command as a system service, andphp gluo uninstall <command>removes it. The service runsphp gluoitself, so the Gluo container is built when it starts, and its command line is written in full: the PHP binary, the absolute path tophp gluo,--env, the command and its arguments, in the project root. The command must passlist --check. The service is named after the project directory and the command (shop-payment-sweep), or--name;--descriptionsets its description. Scriptify'sinstallCommand()writes it.--env=<env>(also--env <env>and-e <env>, before or after the command) is consumed by the launcher and exported asAPP_ENVbefore Config is initialised.php gluo listshows both groups;php gluo list --checkadds a status per command (environment, configuration and database connectivity for the built-ins; name, target, visibility and container registration for project commands, without building them) and exits 1 if any is notOK.php gluo migrate <sub>andphp gluo codegen <components>without the colon are accepted so the Composer scripts can stay as aliases. -
BaseScripts::getMigrateHelp()andgetCodeGeneratorHelp()are now public, and their text uses thephp gluosyntax. -
BaseLoginController::getResetEmailSubject()sets the subject of the password reset email, next to the existinggetResetEmailTemplate()hook. The subject used to be hard-coded as"Service - Password Reset", a leftover from the template's rename placeholder, which nothing could rename once the code moved intovendor/. The default is now"Password Reset"; override the hook to brand it:protected function getResetEmailSubject(): string{return 'Acme - Password Reset';} -
The generated REST controller (Repository pattern) receives its service through the constructor instead of calling
Config::get()in every method. The project'sServermust resolve controllers from the container: the starter does it withwithContainer()inconfig/dev/03-api.phpand anAutowirerule for the controller namespace inconfig/dev/07-controllers.php. Codegen writes no DI entry for controllers; repositories and services still get one; when04-repositories.php/05-services.phpdoes not exist, codegen prints a warning to register the class by hand. -
FakeApiRequestergives itsMockServerthe Gluo container (withContainer(Config::getContainer(), allowUnregistered: true)), so controllers with constructor dependencies work under test as they do in production. -
codegenemitsmaxLengthforcharandvarcharcolumns, on the model property and on the request body of the generated controllers. Templates get the declared length asfield.openapi_max_length(0for any other type). -
OpenAPI 3.1.
php gluo openapiwrites the spec as OpenAPI 3.1.0 (it was swagger-php's default, 3.0.0). A nullable property is written as a type union ("type": ["string", "null"]) instead ofnullable: true; the attributes in the code do not change. OverrideBaseScripts::getOpenApiVersion()to keep 3.0.0. -
codegen:
minLength: 1for required text. ANOT NULLchar/varchar/textcolumn with no default (not a key, not generated) getsminLength: 1in the model and in thePOSTbody, so an empty string is refused like a missing value, as Laravel'srequiredand Symfony'sNotBlankdo. New template variable:openapi_min_length. -
codegen: a test that reaches the server. The generated test has
testInvalidPayloadIsRejectedByTheServer: aPUTwithout the primary key, aPOSTwithout the required fields and an empty value for eachminLengthcolumn, sent without the OpenAPI spec on the client side so they reach#[ValidateRequest], expecting 400.
Bug Fixes
-
php gluo openapiscans theOa*traits only when a class uses them.getOpenApiScanPaths()always added gluo-core'sTrait/directory, so in a project whose models use none ofOaCreatedAt,OaUpdatedAt,OaDeletedAtswagger-php warned about each of their properties ("Unexpected @OA\Property(), expected to be inside @OA\Schema"), which belonged to no schema. Now only the traits a PHP file ofsrc/names are scanned (a model usingOaCreatedAtandOaUpdatedAtno longer gets a warning forOaDeletedAt); a project that overrodegetOpenApiScanPaths()to avoid the warnings no longer needs to. -
The generated test chose between an integer and a UUID key by the column's database type being exactly
int, so a key declaredint(11)got UUID paths (/product/<uuid>). It now uses the PHP type of the column. -
The Repository-pattern controller accepted
createdAt,updatedAtanddeletedAtin thePOSTbody, which the ActiveRecord one leaves out: the dates are set by the database and the traits. Both now leave them out. -
codegen wrote the
OA\Propertyarguments out of the order the attribute declares them (maxLengthbeforenullable), which IDE inspections flag. It now writestype,format,nullable,maxLength,minLength. -
The generated list endpoints documented
orderByandfilterquery parameters but only readpageandsize.orderByis now applied, through the newByJG\Gluo\Util\OrderBy::fromQueryString(): one or more fields separated by commas, each optionally followed byascordesc, accepted only when they are fields of the model (by property or column name) and rejected with a 400 otherwise, so nothing from the request reaches the SQL.filteris no longer generated: it had no defined format.BaseService::list()takes an optional third argument,$orderBy, passed to the repository. The generated test gainstestListOrderBy.Controllers generated before this change keep working and keep ignoring both parameters; regenerate the controller, or copy the list method from a new one.
-
BaseLoginController::postResetRequest()validated the request twice: once through the#[ValidateRequest]attribute on the route and again withOpenApiContext::validateRequest(). It now readsValidateRequest::getPayload(), likepost(), so the route must keep its#[ValidateRequest]attribute (the starter'sLoginControllerhas it). -
BaseRepository::getUuid()asked the database for the UUID withSELECT upper(uuid()), a MySQL-only function, so the password-reset flow ofBaseLoginControllerfailed on SQLite (and PostgreSQL) withno such function: uuid. It now generates a random version 4 UUID in PHP, in the same format (upper case, with dashes), and needs no database connection. -
codegenread the table with MySQL-only statements (EXPLAIN,SHOW INDEX,information_schema.key_column_usage), so it failed on any other database; on SQLite withnear "payment": syntax error. It now reads the table through the anydataset-db dialect (getTableMetadata(),getTableIndexes(),getTableForeignKeys()), on MySQL, SQLite, PostgreSQL, SQL Server and Oracle. Types such as PostgreSQL'scharacter varying(n),numericandtimestamp without time zoneare mapped like their MySQL counterparts.The template variables are unchanged, so custom templates keep working. On MySQL there are two differences:
field.keyisPRIor empty (it used to beUNIorMULfor other indexed columns), andfield.extraisauto_increment,VIRTUAL GENERATED(for any generated column, stored ones included, which are not writable either) or empty.indexesno longer has an entry for the primary key, which the templates skipped anyway.A table whose primary key is not auto-increment (a
binary(16)UUID) gets a model with micro-orm'sTableUuidPKAttribute, which generates the UUID in PHP on every database, instead ofTableMySqlUuidPKAttribute, which asked MySQL foruuid_to_bin(uuid())and is now deprecated.BaseScripts::buildCodegenData()now takes theColumnMetadata,IndexMetadataandForeignKeyMetadataobjects of anydataset-db instead of the raw MySQL rows; a project that overrides it has to follow. The newbuildCodegenDataFromDatabase()reads them. -
BaseApiTestCasereset the test database withMySqlDatabaseunlessgetDatabaseClass()was overridden, so the tests of a PostgreSQL or SQLite project failed withScheme "sqlite" does not found. It now registers every class of the newBaseScripts::MIGRATION_DATABASES(MySQL, PostgreSQL, SQLite), the listphp gluo migratealready used, and the scheme ofDBDRIVER_CONNECTIONpicks one.getDatabaseClass()returnsnullby default; a project that overrides it keeps its class. -
php gluo list --checkmasks the database password in aDB_UNREACHABLEmessage with a literal replace, which corrupted the message when the password was short: a password200turnedSQLSTATE[HY000] [2002]intoSQLSTATE[HY000] [****2]. The literal replace now needs a password of at leastChecker::MASK_MIN_LENGTH(4) characters; a password inside a URL (://user:pw@) is masked whatever its length, including"0"and a password-only URL (://:pw@), which was left unmasked before.
Requirements
- PHP 8.3, 8.4, 8.5 and 8.6 are now supported:
"php": ">=8.3 <8.7". The previous<8.6upper bound excluded PHP 8.6, since<8.6is exclusive.
ByJG dependencies
byjg/anydataset-dbis now^7.0.byjg/authuseris now^7.0.byjg/cache-engineis now^7.0.byjg/configis now^7.0.byjg/jinja-phpis now^7.0.byjg/mailwrapperis now^7.0.byjg/micro-ormis now^7.0.byjg/migrationis now^7.0.byjg/restserveris now^7.0.byjg/scriptify^7.0is a new requirement: the launcher runs project commands through it.byjg/serializeris now^7.0.byjg/swagger-testis now^7.0.
While 7.0 is unreleased these resolve to 7.0.x-dev from each component's
7.0 branch, via minimum-stability: dev with prefer-stable: true.
Toolchain
-
PHPUnit updated to
^12.5. -
Psalm is installed as
psalm/pharinstead ofvimeo/psalm.vimeo/psalmlists the PHP versions it supports and no published release includes 8.6, so as a dev dependency it madecomposer installfail on the 8.6 build job before any test ran.psalm/pharrequires onlyphp ^8.2and bundles its own dependencies, so it installs on every PHP version in the matrix and cannot conflict with the project's. Psalm itself still refuses to run on 8.6, which is why the Psalm job uses 8.5.composer psalmruns it. -
PHPUnit 13 is deliberately not used. It requires PHP
>=8.4.1, breaking the 8.3 floor.
Continuous Integration
- The build matrix now includes PHP 8.6.
- The Psalm job runs on PHP 8.5.
Housekeeping
phpunit.xml.distrenamed tophpunit.xml.- Added the
LICENSEfile (MIT); the license was already declared incomposer.json.