typo3/cms-core
The core package of TYPO3, a free enterprise-grade open source CMS. It provides the framework, APIs, backend and frontend rendering, extension system, and shared libraries needed to build and run TYPO3 websites and custom extensions.
Treat typo3/cms-core as the kernel of a competing PHP framework, not as a Composer package you drop into a Laravel app. It ships its own DI container, HTTP kernel, event dispatcher, console application, and ORM layer (Doctrine DBAL) — none of which coexist cleanly with Laravel's. There are exactly two realistic ways a Laravel developer touches this package:
Step 1 — get a running instance, not the package. Never composer require typo3/cms-core. Install a distribution that pins a coherent set of typo3/cms-* packages:
composer create-project typo3/cms-base-distribution my-site
cd my-site
php vendor/bin/typo3 setup # or: ./vendor/bin/typo3 setup --no-interaction
php vendor/bin/typo3 serve # dev server, or point your vhost at ./public
Step 2 — read the bootstrap before anything else. Open index.php in the distribution root. It is six lines that tell you the whole architecture:
// SystemEnvironmentBuilder::run() → constants, env, error handling
// Bootstrap::init() → package discovery, compiled DI container, failsafe fallback
// Core\Http\Application → PSR-15 middleware stack, routing, response
That failsafe fallback is the key detail: when the compiled container cannot be built (fresh install, broken Services.yaml), TYPO3 falls back to a hand-wired minimal container assembled from Classes/ServiceProvider.php files. Half of the "why isn't my class autowired?" mysteries end here.
Step 3 — go to typo3/sysext/core/ and the docs. Every system extension is a Composer package with an identical internal shape (Classes/, Configuration/, Resources/Private, Tests/, ext_localconf.php, ext_tables.sql). First reading, in order:
https://docs.typo3.org/m/typo3/reference-coreapi/main/en-us/ — Extension System, Dependency Injection, Events, Middleware.typo3/sysext/core/Documentation/Changelog/ — the single best source for version-specific API changes; this is TYPO3's equivalent of upgrading Laravel between majors.Core\Utility\GeneralUtility, Core\DataHandling\DataHandler, Core\Database\ConnectionPool — the three classes you will actually call.First useful task for a Laravel dev: write a console command that exports a page tree and its content elements to JSON, then consume it from a Laravel importer. It is a bounded, low-risk task that teaches you package discovery, DI, Doctrine DBAL, and the CLI in one go — and it is the concrete first step of the headless pattern below.
| Laravel | TYPO3 core |
|---|---|
config/*.php |
$GLOBALS['TYPO3_CONF_VARS'] + config/system/additional.php + per-site YAML |
| Service providers | Configuration/Services.yaml (autowired) or Services.php (compiler passes) |
app/Http/Middleware + Kernel.php |
Configuration/RequestMiddlewares.php, PSR-15 stack per request type |
| Events/Listeners | PSR-14 events, #[AsEventListener] attribute |
routes/web.php |
Site configuration YAML (frontend); Configuration/Backend/{Routes,AjaxRoutes,Modules}.php (backend) |
php artisan |
vendor/bin/typo3 (Configuration/Commands.php or #[AsCommand]) |
| Blade | Fluid; ViewHelpers resolve through the DI container |
| Eloquent / migrations | Doctrine DBAL, ext_tables.sql + schema migrator + Upgrade Wizards |
Queue::dispatch() |
Scheduler extension (typo3/cms-scheduler) hitting CLI tasks; no native queue |
.env |
.env exists, but only a subset is consumed; real config lives in config/system/ |
This is the highest-value integration for a Laravel team, and the one where typo3/cms-core is genuinely load-bearing without you writing TYPO3 extension code.
EXT:headless, EXT:t3api, or the core JSON view of Extbase controllers) rather than the built-in Fluid renderer.config/sites/<identifier>/), which is where TYPO3 keeps base variants, languages, routing enhancers, and error handling. Multiple sites share one core and one database — this is the feature that justifies TYPO3 in the first place.#[AsEventListener]) that POSTs a purge webhook to your Laravel app. That single listener is often the only core-touching code you write.Configuration/TCA/Overrides/), keep the presentation model in Laravel. Do not attempt to render Fluid templates inside a Laravel request cycle.Register in Configuration/RequestMiddlewares.php, keyed by frontend or backend, with before/after ordering resolved by Http\MiddlewareStackResolver:
return [
'frontend' => [
'vendor/request-id' => [
'target' => \Vendor\Ext\Middleware\RequestIdMiddleware::class,
'before' => ['typo3/cms-frontend/site'],
'after' => ['typo3/cms-core/normalized-params-attribute'],
],
],
];
The middleware itself is ordinary PSR-15 — no TYPO3 base class. Use this for correlation IDs, security headers, or edge-cache hints. Do not use it for authentication; the backend authentication middleware has a strict ordering contract.
$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'] hooks are legacy. New integration points are events dispatched through the core's EventDispatcher, consumed with an attribute:
#[AsEventListener]
final class PurgeSiteCacheOnPublish
{
public function __invoke(AfterRecordPublishedEvent $event): void
{
// push to Laravel, invalidate CDN, etc.
}
}
The attribute registration works because Services.yaml in your extension uses autoconfigure: true over a resource: '../Classes/*' sweep. If you hand-write service definitions, add the event.listener tag yourself.
For ETL work (content out of TYPO3, into Laravel; or assets in), a command under Classes/Command/ is far safer than touching DataHandler directly:
$connection = GeneralUtility::makeInstance(ConnectionPool::class)
->getConnectionForTable('tt_content');
$rows = $connection->fetchAllAssociative(
'SELECT uid, pid, CType, header, bodytext FROM tt_content WHERE deleted = 0 AND hidden = 0'
);
Use ConnectionPool, never $GLOBALS['TYPO3_DBAL_CONNECTION'] (that name appears in older docs and is not a stable contract). Doctrine's fetchAllAssociative/executeStatement map cleanly onto Laravel's query builder habits; the difference is that table names are unquoted-by-default and uid/pid/t3ver_* columns carry semantics you must respect if writing.
Configuration/TCA/Overrides/<table>.php.Configuration/Services.yaml with tags; consume extension points via !tagged_iterator (e.g. mfa.provider, fal.file_renderer, softreference.parser).The core is a read-only subtree split regenerated by the release pipeline. A local patch will be silently overwritten on the next composer update.
Use the core's own harness rather than adapting PHPUnit yourself:
./Build/Scripts/runTests.sh -s unit
./Build/Scripts/runTests.sh -s functional -d sqlite # default; fastest feedback
./Build/Scripts/runTests.sh -s cgl
Extensions boot TYPO3 via TYPO3\CMS\Core\Tests\FunctionalTestCase plus the Fixture trait, which loads XML/CSV into a real schema. There is no equivalent of Laravel's RefreshDatabase; the trait is the mechanism. Do not invoke phpunit directly in core work — the wrapper supplies the PHP version, DB service, and bootstrap.
Never add typo3/cms-core to require. It is a [READ-ONLY] subtree split. Depend on typo3/cms-base-distribution (new projects) or individual typo3/cms-* packages. A direct requirement produces version skew and missing transitive sysexts; the failure surfaces as a container compile error, not a Composer error, which makes it very hard to diagnose.
GitHub issues and PRs are ignored. The repository is a synchronized mirror; development happens on Gerrit (https://review.typo3.org/q/project:Packages/TYPO3.CMS) and issues live on Forge. If you need to query the tracker programmatically, use Redmine's JSON API with curl's default user agent — browser-like agents get walled by the Anubis bot check:
curl -H "Accept: application/json" "https://forge.typo3.org/issues/12345.json?include=journals"
Class aliasing hides legacy namespaces. typo3/class-alias-loader maps t3lib_div → TYPO3\CMS\Core\Utility\GeneralUtility and dozens more. This keeps old extensions alive but means autocomplete and grep lie to you. Check Build/ClassAliasMap.php before concluding a class does not exist, and expect alias maps to shift across majors.
Two containers, and autowiring only covers one. Your Services.yaml is compiled into the main container. Code that must run in the install tool or with broken configuration runs in the failsafe container, assembled by hand from Classes/ServiceProvider.php in core, backend, install, fluid, and dashboard. Symptom: a service injects perfectly on the frontend and is null in the installer. Fix: wire it manually in the corresponding ServiceProvider.
Constructor injection only; GeneralUtility::makeInstance() is a code smell. Prefer autowired constructor arguments. Never inject request-scoped services — ContentObjectRenderer, Extbase's UriBuilder, Context — into singletons or into Fluid ViewHelpers. The Fluid compiler pass makes every ViewHelperInterface public and non-shared so the resolver can build them per call; a stateful ViewHelper breaks silently under repeated rendering.
Config lives in three places and none of them is config/. $GLOBALS['TYPO3_CONF_VARS'] (ext_localconf.php, config/system/*.php), per-site YAML in config/sites/<id>/, and .env for a small allowlisted set. After editing DI config or Services.yaml, clear the compiled container (vendor/bin/typo3 cache:flush or remove var/cache/code/container) or your changes will appear not to apply.
No migrations in the Laravel sense. Schema comes from ext_tables.sql parsed by the core schema migrator; data changes go through Upgrade Wizards (Classes/Updates/). Never write DDL against tt_content by hand — TCA, the schema migrator, and the backend form engine will disagree about the table.
Routing is asymmetric. Backend routes are PHP arrays under Configuration/Backend/; frontend routing is generated entirely from site configuration and Core\Routing. Do not look for a routes.php. A "404 that works in dev" is almost always a site-configuration issue, not a routing bug.
XLIFF is strict. Translation files are locallang*.xlf and must match the extension key. Translate through LocalizationUtility::translate() in PHP or the f:translate ViewHelper in Fluid — raw LLL: references in PHP do not resolve. Run ./Build/Scripts/runTests.sh -s checkIntegrityXliff (with -s normalizeXliff to auto-fix formatting) before committing; CI gates on it.
Resources/Public/{JavaScript,Css} is build output. Sources live in Build/Sources/TypeScript/<ext-key>/ and Build/Sources/Sass/. Editing the compiled assets is silently lost on next build. Note the naming shift: TypeScript folders use dashes (rte-ckeditor), sysext keys use underscores (rte_ckeditor).
Debugging entry points that actually work:
vendor/bin/typo3 with no arguments lists all commands; database:upgrade, cache:flush, extension:setup are the workhorses./typo3/install.php) for environment and schema diagnostics — this is where the failsafe container surfaces.listExceptionCodes in the test runner reports duplicate and missing exception codes; TYPO3 relies on numeric exception codes as stable documentation anchors, so a new exception without a unique code fails CI.typo3/sysext/core/Documentation/Changelog/ for deprecation notices — read it before, not after, a major upgrade.When you touch an extension point, add a changelog entry. Replacing a legacy hook with a PSR-14 event requires both the event class and a Feature-*.rst file. ./Build/Scripts/runTests.sh -s watchRst core interactive is the live-preview and changelog wizard.
PHP version floor is strict. Recent majors demand PHP 8.1 (v13 LTS) to 8.2+ (v14/main), with strong typing enforced. Add declare(strict_types=1); to your extensions; missing union or nullable hints that PHP tolerates will fail TYPO3's static analysis baseline (-s phpstan, config in Build/phpstan/).
How can I help you explore Laravel packages today?