
Most TYPO3 projects I see during upgrades have the same Composer story. The core and the third-party extensions come from Packagist, fine. But the sitepackage and the two or three project extensions sit somewhere in public/typo3conf/ext/, committed directly, outside Composer. They have no version, no declared dependencies, and nobody can tell which core versions they support. Then the upgrade starts, and you find out the hard way.
When I moved hoja.in from TYPO3 7.6 classic mode to a fresh 14.3 Composer project this year, I set up the Composer part so that the next upgrade is easier. These are the four pieces.
1. Your own extensions as path packages
A path repository tells Composer: packages can also be found in these local folders. Each extension gets its own composer.json with a name, a type and its requirements. This is the real root composer.json of hoja.in, shortened:
{
"name": "harvisoft/myport",
"type": "project",
"repositories": [
{
"type": "path",
"url": "Source/Extension/*"
}
],
"require": {
"php": "^8.2",
"harvisoft/sitepackage": "@dev",
"typo3/cms-core": "^14.3",
"typo3/cms-form": "^14.3"
},
"config": {
"platform": {
"php": "8.4"
},
"sort-packages": true
},
"minimum-stability": "stable",
"prefer-stable": true
}Many projects use packages/* as the folder name; I used Source/Extension/*. The name does not matter, the glob does: every folder with a composer.json below it becomes an installable package. Composer symlinks it into vendor/ (on hoja.in, vendor/harvisoft/sitepackage points to ../../Source/Extension/sitepackage/), and the TYPO3 Composer installers make it an active extension.
The extension’s own composer.json declares what it really needs. For the hoja.in sitepackage that is PHP 8.2, typo3/cms-core, cms-fluid-styled-content, cms-form and cms-frontend, all ^14.3, plus the extra.typo3/cms.extension-key sitepackage. Now the next upgrade has a clear signal: when you raise the core to v15, Composer refuses until the sitepackage says it supports v15. That is the point. The refusal is a to-do list.
The @dev warning
composer validate on hoja.in is honest with me:
./composer.json is valid, but with a few warnings
# General warnings
- require.harvisoft/sitepackage : unbound version constraints (@dev) should be avoidedThe path package has no version, so Composer gives it a dev version and the root needs @dev to accept it. If you run composer validate --strict in CI, warnings fail the job. The cleaner way is to give the path package a version through the repository’s versions option and require it with a normal constraint:
"repositories": [
{
"type": "path",
"url": "Source/Extension/*",
"options": {
"versions": {
"harvisoft/sitepackage": "1.0.0"
}
}
}
],
"require": {
"harvisoft/sitepackage": "^1.0"
}That is on my list for hoja.in; today it still says @dev.
2. config.platform.php: resolve for production, not for your laptop
Composer resolves dependencies for the PHP version it runs on, unless you tell it otherwise. DDEV runs PHP 8.4 for hoja.in. If the hosting ran 8.2 and some package had a newer release that needs 8.3, my lock file would contain it, and production would break on the first composer install.
config.platform.php fakes the PHP version for resolution. hoja.in pins it to 8.4, the version the hosting must provide. The runbook has a warning next to it: lower it only together with a fresh composer update, because the setting changes what gets resolved, not what is already in the lock file.
Two related habits:
- Never use
--ignore-platform-reqsto “make it work”. It produces a lock file for a PHP version nobody runs. My migration plugin’s guard hook denies it for the AI agent, and I wish I had a hook for myself sometimes. - On the server, run
composer check-platform-reqs --lockbefore switching. It compares the lock file’s requirements with the PHP binary and extensions that are really there.
3. composer audit in CI
composer audit checks your packages against the security advisories that Packagist knows, including the TYPO3 security advisories for core and extensions. It needs no extra tool:
composer audit --locked --no-dev
composer audit --locked --format=json > audit.json--locked audits the lock file instead of the installed vendor/, which is what you want in CI. --no-dev skips dev-only packages if you only care about what goes to production. The command exits with a non-zero code when it finds something, so a pipeline step is one line. A GitHub Actions job could look like this:
jobs:
composer:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
- run: composer validate --strict
- run: composer audit --lockedRun it on a schedule too, not only on push. A project that nobody touches for three months still gets new advisories.
4. Abandoned packages during an upgrade
Packagist lets maintainers mark a package as abandoned, optionally with a replacement. composer audit reports abandoned packages as well, and the option --abandoned decides what happens: ignore, report or fail. The project default lives in config.audit.abandoned. In the Composer version of my DDEV container (2.10.3), composer config audit shows "abandoned":"fail", so an abandoned package fails the audit.
During an upgrade, abandoned and incompatible packages are the real blockers. My routine:
- Ask Composer directly what blocks the target:
composer why-not typo3/cms-core "^14.3"lists every package whose constraints prevent it. - For each blocker, check Packagist and the repository: is there a release for the target, a maintained fork, an open pull request?
- If not, decide between replacing and dropping. Write the decision down with a reason.
For hoja.in the blockers were older than Composer in that project, because 7.6 ran in classic mode: powermail had no v14 release, so the contact form moved to the core form framework; realurl was replaced by site configuration and slugs; flux, fluidpages and vhs were replaced by backend layouts and PAGEVIEW. Every one of those is a line in the migration ledger, with the reason. If an extension is abandoned and you still need it for a while, set --abandoned=report for that run and keep a ticket open, rather than switching the check off for good.
Checklist
- Every own extension is a path package with its own
composer.json. - Real version constraints instead of
@dev, socomposer validate --strictpasses. config.platform.phpmatches production.composer audit --lockedon every push and on a schedule.- Blockers and replacements written down before the upgrade starts.
Feedbacks are the champion’s breakfast. Drop a comment if your setup looks different and why.