diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 480c2b3..e341f6a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -30,8 +30,8 @@ composer test
```
Commit both `composer.lock` and `package-lock.json` for reproducible builds.
-`migrate` and `seed` are unavailable until M2 (#12); they exit nonzero and leave
-#10 open. Never remove volumes with `down -v`; the wrapper forbids it.
+`migrate` and `seed` are available via `./dev.sh` (M2 #12). Never remove volumes
+with `down -v`; the wrapper forbids it.
`composer lint` enforces PSR-12 on PHP in `src`, `public`, `config`, `bin`,
`templates`, `db/migrations` and `tests`. `composer test` runs foundation checks
diff --git a/README.md b/README.md
index 8b722f6..7c433f2 100644
--- a/README.md
+++ b/README.md
@@ -79,8 +79,8 @@ and Node tooling runs inside containers.
```
`composer test` and `composer lint` also work on a host with PHP 8.3+ and Composer 2.
-`composer audit` checks locked PHP dependencies. `migrate` and `seed` are unavailable
-until M2 (#12); they exit nonzero and leave #10 open.
+`composer audit` checks locked PHP dependencies. `migrate` and `seed` are available
+via `./dev.sh` (M2 #12).
Asset builds use a locked `package-lock.json` inside a container; host Node is not
required. The generated `public/build/app.css` is served statically by nginx.
diff --git a/composer.json b/composer.json
index 3ab2879..2eba134 100644
--- a/composer.json
+++ b/composer.json
@@ -13,6 +13,7 @@
},
"require-dev": {
"phpunit/phpunit": "^12.0",
+ "robmorgan/phinx": "^0.16.12",
"squizlabs/php_codesniffer": "^3.13.5"
},
"autoload": {
diff --git a/composer.lock b/composer.lock
index 1d8b856..baac573 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "4651e99c6ead8679c8fa5e59ddb8d69f",
+ "content-hash": "9c13b36d41dcba4dd2c6a4d791712670",
"packages": [
{
"name": "fig/http-message-util",
@@ -971,6 +971,472 @@
}
],
"packages-dev": [
+ {
+ "name": "cakephp/chronos",
+ "version": "3.5.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/cakephp/chronos.git",
+ "reference": "00c16f165a762226d58f9cf88f43e54e41093f84"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/cakephp/chronos/zipball/00c16f165a762226d58f9cf88f43e54e41093f84",
+ "reference": "00c16f165a762226d58f9cf88f43e54e41093f84",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "psr/clock": "^1.0"
+ },
+ "provide": {
+ "psr/clock-implementation": "1.0"
+ },
+ "require-dev": {
+ "cakephp/cakephp-codesniffer": "^5.0",
+ "phpunit/phpunit": "^10.5.58 || ^11.5.3 || ^12.1.3"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Cake\\Chronos\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Brian Nesbitt",
+ "email": "brian@nesbot.com",
+ "homepage": "http://nesbot.com"
+ },
+ {
+ "name": "The CakePHP Team",
+ "homepage": "https://cakephp.org"
+ }
+ ],
+ "description": "A simple API extension for DateTime.",
+ "homepage": "https://cakephp.org",
+ "keywords": [
+ "date",
+ "datetime",
+ "time"
+ ],
+ "support": {
+ "issues": "https://github.com/cakephp/chronos/issues",
+ "source": "https://github.com/cakephp/chronos"
+ },
+ "time": "2026-09-06T02:30:12+00:00"
+ },
+ {
+ "name": "cakephp/core",
+ "version": "5.4.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/cakephp/core.git",
+ "reference": "55c9f6ec8a11f6c946cad5bc6e8da18d6d98ae06"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/cakephp/core/zipball/55c9f6ec8a11f6c946cad5bc6e8da18d6d98ae06",
+ "reference": "55c9f6ec8a11f6c946cad5bc6e8da18d6d98ae06",
+ "shasum": ""
+ },
+ "require": {
+ "cakephp/utility": "^5.4.0",
+ "league/container": "^5.1",
+ "php": ">=8.2",
+ "psr/container": "^1.1 || ^2.0"
+ },
+ "provide": {
+ "psr/container-implementation": "^2.0"
+ },
+ "suggest": {
+ "cakephp/cache": "To use Configure::store() and restore().",
+ "cakephp/event": "To use PluginApplicationInterface or plugin applications.",
+ "league/container": "To use Container and ServiceProvider classes"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-5.next": "5.5.x-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "functions.php"
+ ],
+ "psr-4": {
+ "Cake\\Core\\": "."
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "CakePHP Community",
+ "homepage": "https://github.com/cakephp/core/graphs/contributors"
+ }
+ ],
+ "description": "CakePHP Framework Core classes",
+ "homepage": "https://cakephp.org",
+ "keywords": [
+ "cakephp",
+ "core",
+ "framework"
+ ],
+ "support": {
+ "forum": "https://stackoverflow.com/tags/cakephp",
+ "irc": "irc://irc.freenode.org/cakephp",
+ "issues": "https://github.com/cakephp/cakephp/issues",
+ "source": "https://github.com/cakephp/core"
+ },
+ "time": "2026-08-24T11:32:41+00:00"
+ },
+ {
+ "name": "cakephp/database",
+ "version": "5.4.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/cakephp/database.git",
+ "reference": "25abb44fdbb80b0700ae0a8649a19653253940ff"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/cakephp/database/zipball/25abb44fdbb80b0700ae0a8649a19653253940ff",
+ "reference": "25abb44fdbb80b0700ae0a8649a19653253940ff",
+ "shasum": ""
+ },
+ "require": {
+ "cakephp/chronos": "^3.3",
+ "cakephp/core": "^5.4.0",
+ "cakephp/datasource": "^5.4.0",
+ "cakephp/event": "^5.4.0",
+ "php": ">=8.2",
+ "psr/log": "^3.0"
+ },
+ "require-dev": {
+ "cakephp/i18n": "^5.4.0",
+ "cakephp/log": "^5.4.0",
+ "cakephp/utility": "^5.4.0"
+ },
+ "suggest": {
+ "cakephp/i18n": "If you are using locale-aware datetime formats.",
+ "cakephp/log": "If you want to use query logging without providing a logger yourself.",
+ "cakephp/utility": "If you want to use EnumLabelTrait."
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-5.next": "5.5.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Cake\\Database\\": "."
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "CakePHP Community",
+ "homepage": "https://github.com/cakephp/database/graphs/contributors"
+ }
+ ],
+ "description": "Flexible and powerful Database abstraction library with a familiar PDO-like API",
+ "homepage": "https://cakephp.org",
+ "keywords": [
+ "abstraction",
+ "cakephp",
+ "database",
+ "database abstraction",
+ "pdo"
+ ],
+ "support": {
+ "forum": "https://stackoverflow.com/tags/cakephp",
+ "irc": "irc://irc.freenode.org/cakephp",
+ "issues": "https://github.com/cakephp/cakephp/issues",
+ "source": "https://github.com/cakephp/database"
+ },
+ "time": "2026-10-03T03:38:06+00:00"
+ },
+ {
+ "name": "cakephp/datasource",
+ "version": "5.4.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/cakephp/datasource.git",
+ "reference": "cec80f34828d4aeaf24f75097055cd1466e1aec7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/cakephp/datasource/zipball/cec80f34828d4aeaf24f75097055cd1466e1aec7",
+ "reference": "cec80f34828d4aeaf24f75097055cd1466e1aec7",
+ "shasum": ""
+ },
+ "require": {
+ "cakephp/core": "^5.4.0",
+ "php": ">=8.2",
+ "psr/simple-cache": "^2.0 || ^3.0"
+ },
+ "require-dev": {
+ "cakephp/cache": "^5.4.0",
+ "cakephp/collection": "^5.4.0",
+ "cakephp/utility": "^5.4.0"
+ },
+ "suggest": {
+ "cakephp/cache": "If you decide to use Query caching.",
+ "cakephp/collection": "If you decide to use ResultSetInterface.",
+ "cakephp/utility": "If you decide to use EntityTrait."
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-5.next": "5.5.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Cake\\Datasource\\": "."
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "CakePHP Community",
+ "homepage": "https://github.com/cakephp/datasource/graphs/contributors"
+ }
+ ],
+ "description": "Provides connection managing and traits for Entities and Queries that can be reused for different datastores",
+ "homepage": "https://cakephp.org",
+ "keywords": [
+ "cakephp",
+ "connection management",
+ "datasource",
+ "entity",
+ "query"
+ ],
+ "support": {
+ "forum": "https://stackoverflow.com/tags/cakephp",
+ "irc": "irc://irc.freenode.org/cakephp",
+ "issues": "https://github.com/cakephp/cakephp/issues",
+ "source": "https://github.com/cakephp/datasource"
+ },
+ "time": "2026-08-24T11:32:41+00:00"
+ },
+ {
+ "name": "cakephp/event",
+ "version": "5.4.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/cakephp/event.git",
+ "reference": "28e2797bda517cbac7fa683415f7bbc2774de103"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/cakephp/event/zipball/28e2797bda517cbac7fa683415f7bbc2774de103",
+ "reference": "28e2797bda517cbac7fa683415f7bbc2774de103",
+ "shasum": ""
+ },
+ "require": {
+ "cakephp/core": "^5.4.0",
+ "php": ">=8.2"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-5.next": "5.5.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Cake\\Event\\": "."
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "CakePHP Community",
+ "homepage": "https://github.com/cakephp/event/graphs/contributors"
+ }
+ ],
+ "description": "CakePHP event dispatcher library that helps implementing the observer pattern",
+ "homepage": "https://cakephp.org",
+ "keywords": [
+ "cakephp",
+ "dispatcher",
+ "event",
+ "observer pattern"
+ ],
+ "support": {
+ "forum": "https://stackoverflow.com/tags/cakephp",
+ "irc": "irc://irc.freenode.org/cakephp",
+ "issues": "https://github.com/cakephp/cakephp/issues",
+ "source": "https://github.com/cakephp/event"
+ },
+ "time": "2026-08-24T11:32:41+00:00"
+ },
+ {
+ "name": "cakephp/utility",
+ "version": "5.4.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/cakephp/utility.git",
+ "reference": "20f1749f19ed59b5fbc900e683acc72fa2938c17"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/cakephp/utility/zipball/20f1749f19ed59b5fbc900e683acc72fa2938c17",
+ "reference": "20f1749f19ed59b5fbc900e683acc72fa2938c17",
+ "shasum": ""
+ },
+ "require": {
+ "cakephp/core": "^5.4.0",
+ "php": ">=8.2"
+ },
+ "suggest": {
+ "ext-intl": "To use Text::transliterate() or Text::slug()",
+ "lib-ICU": "To use Text::transliterate() or Text::slug()"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-5.next": "5.5.x-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Cake\\Utility\\": "."
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "CakePHP Community",
+ "homepage": "https://github.com/cakephp/utility/graphs/contributors"
+ }
+ ],
+ "description": "CakePHP Utility classes such as Inflector, String, Hash, and Security",
+ "homepage": "https://cakephp.org",
+ "keywords": [
+ "cakephp",
+ "hash",
+ "inflector",
+ "security",
+ "string",
+ "utility"
+ ],
+ "support": {
+ "forum": "https://stackoverflow.com/tags/cakephp",
+ "irc": "irc://irc.freenode.org/cakephp",
+ "issues": "https://github.com/cakephp/cakephp/issues",
+ "source": "https://github.com/cakephp/utility"
+ },
+ "time": "2026-09-26T06:05:31+00:00"
+ },
+ {
+ "name": "league/container",
+ "version": "5.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/thephpleague/container.git",
+ "reference": "58accbc032f0090a9bd08326f93062c5a658b2c5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/thephpleague/container/zipball/58accbc032f0090a9bd08326f93062c5a658b2c5",
+ "reference": "58accbc032f0090a9bd08326f93062c5a658b2c5",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.1",
+ "psr/container": "^2.0.2",
+ "psr/event-dispatcher": "^1.0"
+ },
+ "provide": {
+ "psr/container-implementation": "^1.0"
+ },
+ "replace": {
+ "orno/di": "~2.0"
+ },
+ "require-dev": {
+ "nette/php-generator": "^4.1",
+ "nikic/php-parser": "^5.0",
+ "phpstan/phpstan": "^2.1.11",
+ "phpunit/phpunit": "^10.5.45|^11.5.15|^12.0",
+ "roave/security-advisories": "dev-latest",
+ "scrutinizer/ocular": "^1.9",
+ "squizlabs/php_codesniffer": "^3.9"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-1.x": "1.x-dev",
+ "dev-2.x": "2.x-dev",
+ "dev-3.x": "3.x-dev",
+ "dev-4.x": "4.x-dev",
+ "dev-5.x": "5.x-dev",
+ "dev-master": "5.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "League\\Container\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Phil Bennett",
+ "email": "mail@philbennett.co.uk",
+ "role": "Developer"
+ }
+ ],
+ "description": "A fast and intuitive dependency injection container.",
+ "homepage": "https://github.com/thephpleague/container",
+ "keywords": [
+ "container",
+ "dependency",
+ "di",
+ "injection",
+ "league",
+ "provider",
+ "service"
+ ],
+ "support": {
+ "issues": "https://github.com/thephpleague/container/issues",
+ "source": "https://github.com/thephpleague/container/tree/5.2.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/philipobenito",
+ "type": "github"
+ }
+ ],
+ "time": "2026-03-19T18:52:39+00:00"
+ },
{
"name": "myclabs/deep-copy",
"version": "1.14.0",
@@ -1653,6 +2119,242 @@
],
"time": "2026-10-05T05:49:30+00:00"
},
+ {
+ "name": "psr/clock",
+ "version": "1.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/clock.git",
+ "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d",
+ "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0 || ^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Psr\\Clock\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for reading the clock.",
+ "homepage": "https://github.com/php-fig/clock",
+ "keywords": [
+ "clock",
+ "now",
+ "psr",
+ "psr-20",
+ "time"
+ ],
+ "support": {
+ "issues": "https://github.com/php-fig/clock/issues",
+ "source": "https://github.com/php-fig/clock/tree/1.0.0"
+ },
+ "time": "2022-11-25T14:36:26+00:00"
+ },
+ {
+ "name": "psr/event-dispatcher",
+ "version": "1.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/event-dispatcher.git",
+ "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0",
+ "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\EventDispatcher\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "http://www.php-fig.org/"
+ }
+ ],
+ "description": "Standard interfaces for event handling.",
+ "keywords": [
+ "events",
+ "psr",
+ "psr-14"
+ ],
+ "support": {
+ "issues": "https://github.com/php-fig/event-dispatcher/issues",
+ "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0"
+ },
+ "time": "2019-01-08T18:20:26+00:00"
+ },
+ {
+ "name": "psr/simple-cache",
+ "version": "3.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/simple-cache.git",
+ "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
+ "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.0.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\SimpleCache\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interfaces for simple caching",
+ "keywords": [
+ "cache",
+ "caching",
+ "psr",
+ "psr-16",
+ "simple-cache"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
+ },
+ "time": "2021-10-29T13:26:27+00:00"
+ },
+ {
+ "name": "robmorgan/phinx",
+ "version": "0.16.12",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/cakephp/phinx.git",
+ "reference": "1cce44387a9146dc04c312e6a231b17fb30385cd"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/cakephp/phinx/zipball/1cce44387a9146dc04c312e6a231b17fb30385cd",
+ "reference": "1cce44387a9146dc04c312e6a231b17fb30385cd",
+ "shasum": ""
+ },
+ "require": {
+ "cakephp/database": "^5.0.2",
+ "composer-runtime-api": "^2.0",
+ "php-64bit": ">=8.1",
+ "psr/container": "^1.1|^2.0",
+ "symfony/config": "^4.0|^5.0|^6.0|^7.0|^8.0",
+ "symfony/console": "^6.0|^7.0|^8.0"
+ },
+ "require-dev": {
+ "cakephp/cakephp-codesniffer": "^5.0",
+ "cakephp/i18n": "^5.0",
+ "ext-json": "*",
+ "ext-pdo": "*",
+ "phpunit/phpunit": "^10.5",
+ "symfony/yaml": "^4.0|^5.0|^6.0|^7.0|^8.0"
+ },
+ "suggest": {
+ "ext-json": "Install if using JSON configuration format",
+ "ext-pdo": "PDO extension is needed",
+ "symfony/yaml": "Install if using YAML configuration format"
+ },
+ "bin": [
+ "bin/phinx"
+ ],
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Phinx\\": "src/Phinx/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Rob Morgan",
+ "email": "robbym@gmail.com",
+ "homepage": "https://robmorgan.id.au",
+ "role": "Lead Developer"
+ },
+ {
+ "name": "Woody Gilk",
+ "email": "woody.gilk@gmail.com",
+ "homepage": "https://shadowhand.me",
+ "role": "Developer"
+ },
+ {
+ "name": "Richard Quadling",
+ "email": "rquadling@gmail.com",
+ "role": "Developer"
+ },
+ {
+ "name": "CakePHP Community",
+ "homepage": "https://github.com/cakephp/phinx/graphs/contributors",
+ "role": "Developer"
+ }
+ ],
+ "description": "Phinx makes it ridiculously easy to manage the database migrations for your PHP app.",
+ "homepage": "https://phinx.org",
+ "keywords": [
+ "database",
+ "database migrations",
+ "db",
+ "migrations",
+ "phinx"
+ ],
+ "support": {
+ "issues": "https://github.com/cakephp/phinx/issues",
+ "source": "https://github.com/cakephp/phinx/tree/0.16.12"
+ },
+ "time": "2026-07-03T07:27:17+00:00"
+ },
{
"name": "sebastian/cli-parser",
"version": "4.2.1",
@@ -2717,6 +3419,837 @@
],
"time": "2024-10-20T05:08:20+00:00"
},
+ {
+ "name": "symfony/config",
+ "version": "v7.4.17",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/config.git",
+ "reference": "696e12da8eea497a1a3808d714b43ff67a7df43e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/config/zipball/696e12da8eea497a1a3808d714b43ff67a7df43e",
+ "reference": "696e12da8eea497a1a3808d714b43ff67a7df43e",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/deprecation-contracts": "^2.5|^3",
+ "symfony/filesystem": "^7.1|^8.0",
+ "symfony/polyfill-ctype": "~1.8"
+ },
+ "conflict": {
+ "symfony/finder": "<6.4",
+ "symfony/service-contracts": "<2.5"
+ },
+ "require-dev": {
+ "symfony/event-dispatcher": "^6.4|^7.0|^8.0",
+ "symfony/finder": "^6.4|^7.0|^8.0",
+ "symfony/messenger": "^6.4|^7.0|^8.0",
+ "symfony/service-contracts": "^2.5|^3",
+ "symfony/yaml": "^6.4|^7.0|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Config\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/config/tree/v7.4.17"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-08-20T09:55:18+00:00"
+ },
+ {
+ "name": "symfony/console",
+ "version": "v7.4.20",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/console.git",
+ "reference": "d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/console/zipball/d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d",
+ "reference": "d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/deprecation-contracts": "^2.5|^3",
+ "symfony/polyfill-mbstring": "~1.0",
+ "symfony/service-contracts": "^2.5|^3",
+ "symfony/string": "^7.2|^8.0"
+ },
+ "conflict": {
+ "symfony/dependency-injection": "<6.4",
+ "symfony/dotenv": "<6.4",
+ "symfony/event-dispatcher": "<6.4",
+ "symfony/lock": "<6.4",
+ "symfony/process": "<6.4"
+ },
+ "provide": {
+ "psr/log-implementation": "1.0|2.0|3.0"
+ },
+ "require-dev": {
+ "psr/log": "^1|^2|^3",
+ "symfony/config": "^6.4|^7.0|^8.0",
+ "symfony/dependency-injection": "^6.4|^7.0|^8.0",
+ "symfony/event-dispatcher": "^6.4|^7.0|^8.0",
+ "symfony/http-foundation": "^6.4|^7.0|^8.0",
+ "symfony/http-kernel": "^6.4|^7.0|^8.0",
+ "symfony/lock": "^6.4|^7.0|^8.0",
+ "symfony/messenger": "^6.4|^7.0|^8.0",
+ "symfony/process": "^6.4|^7.0|^8.0",
+ "symfony/stopwatch": "^6.4|^7.0|^8.0",
+ "symfony/var-dumper": "^6.4|^7.0|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Console\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Eases the creation of beautiful and testable command line interfaces",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "cli",
+ "command-line",
+ "console",
+ "terminal"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/console/tree/v7.4.20"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-09-18T15:55:00+00:00"
+ },
+ {
+ "name": "symfony/deprecation-contracts",
+ "version": "v3.7.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/deprecation-contracts.git",
+ "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d",
+ "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.7-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "function.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "A generic function and convention to trigger deprecation notices",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-06-05T06:23:12+00:00"
+ },
+ {
+ "name": "symfony/filesystem",
+ "version": "v7.4.18",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/filesystem.git",
+ "reference": "90d412aa5277c6819db39e7605aa46b1019e3232"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/filesystem/zipball/90d412aa5277c6819db39e7605aa46b1019e3232",
+ "reference": "90d412aa5277c6819db39e7605aa46b1019e3232",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/polyfill-ctype": "~1.8",
+ "symfony/polyfill-mbstring": "~1.8"
+ },
+ "require-dev": {
+ "symfony/process": "^6.4|^7.0|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Filesystem\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides basic utilities for the filesystem",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/filesystem/tree/v7.4.18"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-08-23T10:03:40+00:00"
+ },
+ {
+ "name": "symfony/polyfill-ctype",
+ "version": "v1.37.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-ctype.git",
+ "reference": "141046a8f9477948ff284fa65be2095baafb94f2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2",
+ "reference": "141046a8f9477948ff284fa65be2095baafb94f2",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "provide": {
+ "ext-ctype": "*"
+ },
+ "suggest": {
+ "ext-ctype": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Ctype\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Gert de Pagter",
+ "email": "BackEndTea@gmail.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for ctype functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "ctype",
+ "polyfill",
+ "portable"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-04-10T16:19:22+00:00"
+ },
+ {
+ "name": "symfony/polyfill-intl-grapheme",
+ "version": "v1.43.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-intl-grapheme.git",
+ "reference": "5cac8f4db97208823aa353f59a01faad2da52c75"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/5cac8f4db97208823aa353f59a01faad2da52c75",
+ "reference": "5cac8f4db97208823aa353f59a01faad2da52c75",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "suggest": {
+ "ext-intl": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Intl\\Grapheme\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for intl's grapheme_* functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "grapheme",
+ "intl",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.43.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-09-25T15:46:47+00:00"
+ },
+ {
+ "name": "symfony/polyfill-intl-normalizer",
+ "version": "v1.43.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
+ "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/ebd57ccc7edb93d632e184efbe7693d2517bab3b",
+ "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "suggest": {
+ "ext-intl": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Intl\\Normalizer\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for intl's Normalizer class and related functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "intl",
+ "normalizer",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.43.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-09-23T22:02:09+00:00"
+ },
+ {
+ "name": "symfony/polyfill-mbstring",
+ "version": "v1.43.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-mbstring.git",
+ "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/4d5d331bf731e0aedbc6b2f5e61135b7b7996a72",
+ "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72",
+ "shasum": ""
+ },
+ "require": {
+ "ext-iconv": "*",
+ "php": ">=7.2"
+ },
+ "provide": {
+ "ext-mbstring": "*"
+ },
+ "suggest": {
+ "ext-mbstring": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Mbstring\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for the Mbstring extension",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "mbstring",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-09-24T15:19:04+00:00"
+ },
+ {
+ "name": "symfony/service-contracts",
+ "version": "v3.7.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/service-contracts.git",
+ "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257",
+ "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "psr/container": "^1.1|^2.0",
+ "symfony/deprecation-contracts": "^2.5|^3"
+ },
+ "conflict": {
+ "ext-psr": "<1.1|>=2"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.7-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Contracts\\Service\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Test/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Generic abstractions related to writing services",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "abstractions",
+ "contracts",
+ "decoupling",
+ "interfaces",
+ "interoperability",
+ "standards"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/service-contracts/tree/v3.7.3"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-07-27T15:39:01+00:00"
+ },
+ {
+ "name": "symfony/string",
+ "version": "v7.4.19",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/string.git",
+ "reference": "02731ce2bd26514bec9efc0baef2cd0b0cf67f46"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/string/zipball/02731ce2bd26514bec9efc0baef2cd0b0cf67f46",
+ "reference": "02731ce2bd26514bec9efc0baef2cd0b0cf67f46",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/deprecation-contracts": "^2.5|^3.0",
+ "symfony/polyfill-ctype": "~1.8",
+ "symfony/polyfill-intl-grapheme": "~1.33",
+ "symfony/polyfill-intl-normalizer": "~1.0",
+ "symfony/polyfill-mbstring": "~1.0"
+ },
+ "conflict": {
+ "symfony/translation-contracts": "<2.5"
+ },
+ "require-dev": {
+ "symfony/emoji": "^7.1|^8.0",
+ "symfony/http-client": "^6.4|^7.0|^8.0",
+ "symfony/intl": "^6.4|^7.0|^8.0",
+ "symfony/translation-contracts": "^2.5|^3.0",
+ "symfony/var-exporter": "^6.4|^7.0|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "Resources/functions.php"
+ ],
+ "psr-4": {
+ "Symfony\\Component\\String\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "grapheme",
+ "i18n",
+ "string",
+ "unicode",
+ "utf-8",
+ "utf8"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/string/tree/v7.4.19"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-09-11T14:46:58+00:00"
+ },
{
"name": "theseer/tokenizer",
"version": "2.0.1",
diff --git a/db/migrations/20261011120001_create_folders.php b/db/migrations/20261011120001_create_folders.php
new file mode 100644
index 0000000..0af99fa
--- /dev/null
+++ b/db/migrations/20261011120001_create_folders.php
@@ -0,0 +1,31 @@
+table('folders', ['id' => 'id', 'primary_key' => 'id'])
+ ->addColumn('parent_id', 'integer', ['null' => true, 'signed' => false])
+ ->addColumn('name', 'string', ['limit' => 255])
+ ->addColumn('path', 'text', ['null' => false])
+ ->addColumn('added_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP', 'timezone' => true])
+ ->addForeignKey('parent_id', 'folders', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION'])
+ ->addIndex(['parent_id', 'name'], ['unique' => true, 'name' => 'idx_folders_parent_name'])
+ ->addIndex(['path'], ['unique' => true, 'name' => 'idx_folders_path'])
+ ->create();
+
+ // Root folder entry (will be created by application on first run)
+ // Application ensures root folder exists with path = '/' and parent_id = NULL
+ }
+}
diff --git a/db/migrations/20261011120002_create_models.php b/db/migrations/20261011120002_create_models.php
new file mode 100644
index 0000000..a54c153
--- /dev/null
+++ b/db/migrations/20261011120002_create_models.php
@@ -0,0 +1,30 @@
+table('models', ['id' => 'id', 'primary_key' => 'id'])
+ ->addColumn('folder_id', 'integer', ['null' => false, 'signed' => false])
+ ->addColumn('name', 'string', ['limit' => 255])
+ ->addColumn('notes', 'text', ['null' => true])
+ ->addColumn('source_url', 'string', ['limit' => 2048, 'null' => true])
+ ->addColumn('added_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP', 'timezone' => true])
+ ->addColumn('updated_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP', 'timezone' => true])
+ ->addForeignKey('folder_id', 'folders', 'id', ['delete' => 'RESTRICT', 'update' => 'NO_ACTION'])
+ ->addIndex(['folder_id', 'name'], ['unique' => true, 'name' => 'idx_models_folder_name'])
+ ->addIndex(['added_at'], ['name' => 'idx_models_added_at'])
+ ->create();
+ }
+}
diff --git a/db/migrations/20261011120003_create_files.php b/db/migrations/20261011120003_create_files.php
new file mode 100644
index 0000000..ca54542
--- /dev/null
+++ b/db/migrations/20261011120003_create_files.php
@@ -0,0 +1,38 @@
+table('files', ['id' => 'id', 'primary_key' => 'id'])
+ ->addColumn('model_id', 'integer', ['null' => false, 'signed' => false])
+ ->addColumn('folder_id', 'integer', ['null' => false, 'signed' => false])
+ ->addColumn('name', 'string', ['limit' => 255])
+ ->addColumn('managed_path', 'text', ['null' => false])
+ ->addColumn('hash', 'string', ['limit' => 64, 'null' => true]) // SHA-256 hex
+ ->addColumn('size', 'biginteger', ['null' => true, 'signed' => false])
+ ->addColumn('mime_type', 'string', ['limit' => 128, 'null' => true])
+ ->addColumn('status', 'string', ['limit' => 32, 'default' => 'Present'])
+ ->addColumn('added_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP', 'timezone' => true])
+ ->addColumn('updated_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP', 'timezone' => true])
+ ->addForeignKey('model_id', 'models', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION'])
+ ->addForeignKey('folder_id', 'folders', 'id', ['delete' => 'RESTRICT', 'update' => 'NO_ACTION'])
+ ->addIndex(['model_id', 'managed_path'], ['unique' => true, 'name' => 'idx_files_model_path'])
+ ->addIndex(['hash'], ['name' => 'idx_files_hash'])
+ ->addIndex(['status'], ['name' => 'idx_files_status'])
+ ->addIndex(['folder_id'], ['name' => 'idx_files_folder_id'])
+ ->create();
+ }
+}
diff --git a/db/migrations/20261011120004_create_categories_tags.php b/db/migrations/20261011120004_create_categories_tags.php
new file mode 100644
index 0000000..a6f1a0d
--- /dev/null
+++ b/db/migrations/20261011120004_create_categories_tags.php
@@ -0,0 +1,41 @@
+table('categories', ['id' => 'id', 'primary_key' => 'id'])
+ ->addColumn('parent_id', 'integer', ['null' => true, 'signed' => false])
+ ->addColumn('name', 'string', ['limit' => 128])
+ ->addColumn('description', 'text', ['null' => true])
+ ->addForeignKey('parent_id', 'categories', 'id', ['delete' => 'SET NULL', 'update' => 'NO_ACTION'])
+ ->addIndex(['parent_id', 'name'], ['unique' => true, 'name' => 'idx_categories_parent_name'])
+ ->create();
+
+ // Tags - flat
+ $this->table('tags', ['id' => 'id', 'primary_key' => 'id'])
+ ->addColumn('name', 'string', ['limit' => 128])
+ ->addIndex(['name'], ['unique' => true, 'name' => 'idx_tags_name'])
+ ->create();
+
+ // Model-Tag many-to-many
+ $this->table('model_tags', ['id' => false, 'primary_key' => ['model_id', 'tag_id']])
+ ->addColumn('model_id', 'integer', ['null' => false, 'signed' => false])
+ ->addColumn('tag_id', 'integer', ['null' => false, 'signed' => false])
+ ->addForeignKey('model_id', 'models', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION'])
+ ->addForeignKey('tag_id', 'tags', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION'])
+ ->create();
+ }
+}
diff --git a/db/migrations/20261011120005_create_thumbnails.php b/db/migrations/20261011120005_create_thumbnails.php
new file mode 100644
index 0000000..587d69a
--- /dev/null
+++ b/db/migrations/20261011120005_create_thumbnails.php
@@ -0,0 +1,32 @@
+table('thumbnails', ['id' => 'id', 'primary_key' => 'id'])
+ ->addColumn('model_id', 'integer', ['null' => false, 'signed' => false])
+ ->addColumn('managed_path', 'text', ['null' => false])
+ ->addColumn('hash', 'string', ['limit' => 64, 'null' => false]) // SHA-256 hex
+ ->addColumn('size', 'biginteger', ['null' => false, 'signed' => false])
+ ->addColumn('mime_type', 'string', ['limit' => 128, 'null' => false])
+ ->addColumn('width', 'integer', ['null' => true, 'signed' => false])
+ ->addColumn('height', 'integer', ['null' => true, 'signed' => false])
+ ->addColumn('uploaded_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP', 'timezone' => true])
+ ->addForeignKey('model_id', 'models', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION'])
+ ->addIndex(['model_id'], ['name' => 'idx_thumbnails_model_id'])
+ ->addIndex(['hash'], ['unique' => true, 'name' => 'idx_thumbnails_hash'])
+ ->create();
+ }
+}
diff --git a/db/migrations/20261011120006_create_import_jobs.php b/db/migrations/20261011120006_create_import_jobs.php
new file mode 100644
index 0000000..13529cf
--- /dev/null
+++ b/db/migrations/20261011120006_create_import_jobs.php
@@ -0,0 +1,54 @@
+table('import_jobs', ['id' => 'id', 'primary_key' => 'id'])
+ ->addColumn('source_path', 'text', ['null' => false])
+ // Status: Pending, Scanning, Copying, Completed, Failed, Cancelled
+ ->addColumn('status', 'string', ['limit' => 32, 'default' => 'Pending'])
+ ->addColumn('total_files', 'integer', ['null' => false, 'default' => 0, 'signed' => false])
+ ->addColumn('processed_files', 'integer', ['null' => false, 'default' => 0, 'signed' => false])
+ ->addColumn('total_bytes', 'biginteger', ['null' => false, 'default' => 0, 'signed' => false])
+ ->addColumn('copied_bytes', 'biginteger', ['null' => false, 'default' => 0, 'signed' => false])
+ ->addColumn('started_at', 'timestamp', ['null' => true, 'timezone' => true])
+ ->addColumn('completed_at', 'timestamp', ['null' => true, 'timezone' => true])
+ ->addColumn('created_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP', 'timezone' => true])
+ ->addIndex(['status'], ['name' => 'idx_import_jobs_status'])
+ ->addIndex(['created_at'], ['name' => 'idx_import_jobs_created_at'])
+ ->create();
+
+ // Import items - per-file tracking
+ $this->table('import_items', ['id' => 'id', 'primary_key' => 'id'])
+ ->addColumn('job_id', 'integer', ['null' => false, 'signed' => false])
+ ->addColumn('source_path', 'text', ['null' => false])
+ ->addColumn('managed_path', 'text', ['null' => true])
+ ->addColumn('model_id', 'integer', ['null' => true, 'signed' => false])
+ ->addColumn('hash', 'string', ['limit' => 64, 'null' => true]) // SHA-256 hex
+ ->addColumn('size', 'biginteger', ['null' => true, 'signed' => false])
+ // Status: Pending, Copying, Completed, Failed, Skipped
+ ->addColumn('status', 'string', ['limit' => 32, 'default' => 'Pending'])
+ ->addColumn('failure_reason', 'text', ['null' => true])
+ ->addColumn('started_at', 'timestamp', ['null' => true, 'timezone' => true])
+ ->addColumn('completed_at', 'timestamp', ['null' => true, 'timezone' => true])
+ ->addForeignKey('job_id', 'import_jobs', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION'])
+ ->addForeignKey('model_id', 'models', 'id', ['delete' => 'SET NULL', 'update' => 'NO_ACTION'])
+ ->addIndex(['job_id', 'status'], ['name' => 'idx_import_items_job_status'])
+ ->addIndex(['job_id', 'managed_path'], ['name' => 'idx_import_items_job_path'])
+ ->addIndex(['hash'], ['name' => 'idx_import_items_hash'])
+ ->create();
+ }
+}
diff --git a/db/seeds/DefaultDataSeed.php b/db/seeds/DefaultDataSeed.php
new file mode 100644
index 0000000..b760b36
--- /dev/null
+++ b/db/seeds/DefaultDataSeed.php
@@ -0,0 +1,84 @@
+ 'Aircraft',
+ 'description' => 'Planes, helicopters, and drones',
+ ],
+ [
+ 'name' => 'Automotive',
+ 'description' => 'Cars, trucks, and vehicles',
+ ],
+ [
+ 'name' => 'Characters',
+ 'description' => 'Humanoid and creature models',
+ ],
+ [
+ 'name' => 'Props',
+ 'description' => 'Objects and accessories',
+ ],
+ [
+ 'name' => 'Terrain',
+ 'description' => 'Landscapes, buildings, and scenery',
+ ],
+ [
+ 'name' => 'Tools',
+ 'description' => 'Functional items and gadgets',
+ ],
+ ];
+
+ foreach ($categories as $category) {
+ $exists = $this->getAdapter()->fetchRow(
+ "SELECT id FROM categories WHERE name = '"
+ . $category['name'] . "'"
+ );
+ if (!$exists) {
+ $this->getAdapter()->execute(
+ "INSERT INTO categories (name, description) VALUES ('"
+ . $category['name'] . "', '"
+ . $category['description'] . "')"
+ );
+ }
+ }
+
+ // Seed default tags
+ $tags = [
+ ['name' => 'wip'],
+ ['name' => 'completed'],
+ ['name' => 'high-poly'],
+ ['name' => 'low-poly'],
+ ['name' => 'print-ready'],
+ ['name' => 'needs-supports'],
+ ['name' => 'articulated'],
+ ['name' => 'modular'],
+ ['name' => 'multi-part'],
+ ['name' => 'single-piece'],
+ ];
+
+ foreach ($tags as $tag) {
+ $exists = $this->getAdapter()->fetchRow(
+ "SELECT id FROM tags WHERE name = '" . $tag['name'] . "'"
+ );
+ if (!$exists) {
+ $this->getAdapter()->execute(
+ "INSERT INTO tags (name) VALUES ('" . $tag['name'] . "')"
+ );
+ }
+ }
+ }
+}
+
diff --git a/dev.ps1 b/dev.ps1
index b2b5765..4d6da61 100644
--- a/dev.ps1
+++ b/dev.ps1
@@ -4,9 +4,11 @@ $Command = if ($args.Count) { $args[0] } else { '' }
$Forward = @($args | Select-Object -Skip 1)
try {
switch ($Command) {
- { $_ -in 'migrate', 'seed' } {
- [Console]::Error.WriteLine("$Command unavailable until M2 (#12); #10 remains open")
- exit 1
+ 'migrate' {
+ $ComposeArgs = @('run', '--rm', '--no-deps', '-T', '--entrypoint', 'sh', 'app', '-c', "vendor/bin/phinx migrate $($Forward -join ' ')")
+ }
+ 'seed' {
+ $ComposeArgs = @('run', '--rm', '--no-deps', '-T', '--entrypoint', 'sh', 'app', '-c', "vendor/bin/phinx seed:run $($Forward -join ' ')")
}
'up' { $ComposeArgs = @('up', '-d', '--build') }
'down' {
diff --git a/dev.sh b/dev.sh
index 362e7b7..1becd61 100644
--- a/dev.sh
+++ b/dev.sh
@@ -4,7 +4,10 @@ cd -- "$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)"
cmd=${1:-}
if [ "$#" -gt 0 ]; then shift; fi
case "$cmd" in
- migrate|seed) echo "$cmd unavailable until M2 (#12); #10 remains open" >&2; exit 1 ;;
+ migrate)
+ exec docker compose run --rm --no-deps -T --entrypoint sh app -c "vendor/bin/phinx migrate $*" ;;
+ seed)
+ exec docker compose run --rm --no-deps -T --entrypoint sh app -c "vendor/bin/phinx seed:run $*" ;;
down)
for arg do
case "$arg" in
diff --git a/docker-compose.yml b/docker-compose.yml
index 1c39f57..87b7817 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -45,6 +45,7 @@ services:
- ./docker-compose.yml:/var/www/html/docker-compose.yml:ro
- ./docker:/var/www/html/docker:ro
- ./assets:/var/www/html/assets:ro
+ - ./phinx.php:/var/www/html/phinx.php:ro
- type: bind
source: ${MESHVAULT_LIBRARY_HOST_DIR:-./library}
target: ${MESHVAULT_LIBRARY_DIR:-/var/lib/meshvault/library}
diff --git a/docker/README.md b/docker/README.md
index e6db2fe..d89b1fc 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -53,8 +53,8 @@ independent of your current working directory and propagate exit codes.
./dev.sh lint # run composer lint inside the app container
./dev.sh assets # build minified CSS inside the assets container
./dev.sh watch # start Tailwind watch inside the assets container
-./dev.sh migrate # unavailable until M2 (#12); exits nonzero
-./dev.sh seed # unavailable until M2 (#12); exits nonzero
+./dev.sh migrate # run database migrations (M2 #12)
+./dev.sh seed # seed default reference data (M2 #12)
```
`down --volumes` and `down -v` are blocked by the wrapper. Never remove the named
diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile
index c9399c7..6fd8bd4 100644
--- a/docker/php/Dockerfile
+++ b/docker/php/Dockerfile
@@ -1,7 +1,7 @@
FROM php:8.3-fpm-bookworm
-RUN apt-get update && apt-get install -y --no-install-recommends libonig-dev libxml2-dev unzip \
- && docker-php-ext-install mbstring dom \
+RUN apt-get update && apt-get install -y --no-install-recommends libonig-dev libxml2-dev libpq-dev unzip \
+ && docker-php-ext-install mbstring dom pdo_pgsql \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
WORKDIR /var/www/html
diff --git a/phinx.php b/phinx.php
new file mode 100644
index 0000000..b7fb2cb
--- /dev/null
+++ b/phinx.php
@@ -0,0 +1,41 @@
+ [
+ 'migrations' => 'db/migrations',
+ 'seeds' => 'db/seeds',
+ ],
+ 'environments' => [
+ 'default_migration_table' => 'phinxlog',
+ 'default_database' => 'development',
+ 'development' => [
+ 'adapter' => 'pgsql',
+ 'host' => $dbHost,
+ 'port' => $dbPort,
+ 'name' => $dbName,
+ 'user' => $dbUser,
+ 'pass' => $dbPass,
+ 'charset' => 'utf8',
+ ],
+ ],
+ 'version_order' => 'creation',
+ 'migration_base_class' => 'Phinx\Migration\AbstractMigration',
+];
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
index 77f5207..6be2124 100644
--- a/phpcs.xml.dist
+++ b/phpcs.xml.dist
@@ -10,5 +10,12 @@
bin
templates
db/migrations
+ db/seeds
tests
+
+
+ db/migrations/*
+ db/seeds/*
+ 0
+
diff --git a/tests/Integration/MigrationTest.php b/tests/Integration/MigrationTest.php
new file mode 100644
index 0000000..02e98bd
--- /dev/null
+++ b/tests/Integration/MigrationTest.php
@@ -0,0 +1,82 @@
+ \PDO::ERRMODE_EXCEPTION,
+ ]);
+ } catch (\PDOException $e) {
+ self::markTestSkipped('Database connection failed: ' . $e->getMessage());
+ }
+ }
+
+ public function testMigrationTablesExist(): void
+ {
+ if (self::$pdo === null) {
+ $this->markTestSkipped('Database connection not available.');
+ }
+
+ $expectedTables = [
+ 'categories',
+ 'files',
+ 'folders',
+ 'import_items',
+ 'import_jobs',
+ 'model_tags',
+ 'models',
+ 'tags',
+ 'thumbnails',
+ 'phinxlog',
+ ];
+
+ foreach ($expectedTables as $table) {
+ $stmt = self::$pdo->query(
+ "SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = '{$table}'"
+ );
+ $this->assertNotFalse($stmt, "Query failed for table {$table}");
+ $this->assertNotEmpty($stmt->fetch(), "Table {$table} does not exist");
+ }
+ }
+
+ public function testSeedDataExists(): void
+ {
+ if (self::$pdo === null) {
+ $this->markTestSkipped('Database connection not available.');
+ }
+
+ $categories = self::$pdo->query("SELECT name FROM categories ORDER BY name")->fetchAll(\PDO::FETCH_COLUMN);
+ $this->assertContains('Aircraft', $categories);
+ $this->assertContains('Automotive', $categories);
+ $this->assertContains('Tools', $categories);
+
+ $tags = self::$pdo->query("SELECT name FROM tags ORDER BY name")->fetchAll(\PDO::FETCH_COLUMN);
+ $this->assertContains('wip', $tags);
+ $this->assertContains('completed', $tags);
+ $this->assertContains('print-ready', $tags);
+ }
+}
\ No newline at end of file
diff --git a/tests/Integration/TaskRunnerTest.php b/tests/Integration/TaskRunnerTest.php
index 648648f..dcf3739 100644
--- a/tests/Integration/TaskRunnerTest.php
+++ b/tests/Integration/TaskRunnerTest.php
@@ -37,7 +37,13 @@ final class TaskRunnerTest extends TestCase
self::assertStringContainsString('two words', $output);
self::assertStringContainsString(str_replace('\\', '/', $root), str_replace('\\', '/', $output));
}
- foreach (['unknown', 'migrate', 'seed'] as $command) {
+ foreach (['migrate', 'seed'] as $command) {
+ [$code, $output] = $this->invoke([...$runner, $command], $temporary, $environment);
+ self::assertSame(37, $code, $command . ': ' . $output);
+ self::assertStringContainsString('compose', $output);
+ self::assertStringContainsString(str_replace('\\', '/', $root), str_replace('\\', '/', $output));
+ }
+ foreach (['unknown'] as $command) {
[$code, $output] = $this->invoke([...$runner, $command], $temporary, $environment);
self::assertNotSame(0, $code);
self::assertStringNotContainsString('cwd=', $output);