typo3/cms-workspaces
TYPO3 Workspaces adds editorial workflow to TYPO3 CMS, letting teams edit content in draft workspaces, preview and review changes, and publish to the live site only when ready.
The first thing to internalise: typo3/cms-workspaces is not a Laravel package. It is a TYPO3 Core system extension (typo3/sysext/workspaces/), Composer-installable only inside a TYPO3 installation, where it relies on the TYPO3 bootstrap, the Symfony DI container, TcaSchemaFactory, and DataHandler as its write path. There is no ServiceProvider, no facade, no config file you can publish. composer require typo3/cms-workspaces in a Laravel app will install a package whose classes are dead weight unless TYPO3 is bootstrapped around them.
So "getting started" means picking one of two realistic adoption paths:
typo3/sysext/workspaces/Classes/Service/WorkspaceService.php — the public API surface (list workspaces, check access, publish/swap).Classes/Controller/ — the backend module, i.e. the UI flow for review → publish.Configuration/TCA/ + ext_tables.sql — the schema: sys_workspace, sys_workspace_stage, and the versioning columns stamped onto every versionable table.Documentation/ in the sysext, plus the changelogs in typo3/sysext/core/Documentation/Changelog/ for behaviour changes.First use case worth prototyping: a "draft → review → publish" pipeline over one Eloquent model, before you touch anything else. If that model works, everything else is scale.
TYPO3 solves versioning with per-record version rows, not a separate history table. Every versionable record carries t3ver_wsid (workspace id, 0 = live), t3ver_oid (the live record it shadows), t3ver_state (new / delete / move placeholder) and t3ver_stage. The live table is the published state; drafts live as sibling rows in the same table, filtered out by workspace.
That single design decision is the most valuable thing to port. A Laravel-idiomatic translation:
// migration sketch — mirrors t3ver_* columns
$table->unsignedBigInteger('workspace_id')->default(0)->index(); // 0 = live
$table->unsignedBigInteger('version_of')->nullable()->index(); // t3ver_oid
$table->string('version_state')->default('edit'); // new|edit|delete|move
$table->string('stage')->default('editing'); // editing|review|publish
Then a global scope + middleware pair, so controllers never think about workspaces:
// app/Models/Concerns/Versionable.php
public static function bootVersionable(): void
{
static::addGlobalScope('workspace', function (Builder $q) {
$ws = app(WorkspaceContext::class)->id(); // resolved in middleware
$q->where('workspace_id', $ws);
// drafts shadow live rows: hide the live row when a draft exists
$q->whereNotExists(fn ($sub) => $sub->from('posts as d')
->whereColumn('d.version_of', 'posts.id')
->where('d.workspace_id', $ws));
});
}
Patterns worth copying wholesale:
X-Workspace header, bind it as a scoped singleton so queue jobs get a clean one. Never read it from request() deep in a model.ADMCMD_prev with a hash. In Laravel: URL::temporarySignedRoute('posts.show', now()->addHours(2), ['post' => $id, 'ws' => $wsId]), and a middleware that binds the workspace from the query string only if the signature is valid.sys_workspace_stage is a first-class table with per-stage responsible users and notification emails. Model it as a stages table + spatie/laravel-model-states (or a plain enum + transition guard), and treat "publish" as a distinct permission from "edit".DataHandler swaps t3ver_oid records into live and flushes caches per affected page. Your equivalent: copy draft → live row (or flip workspace_id to 0), write an audit row, dispatch a job to purge cache/tag invalidation, all inside DB::transaction(). Emit a domain event (PostPublished) rather than doing side effects inline — that's the PSR-14 event/listener pattern TYPO3 itself mandates for new extension points.sys_workspace ↔ be_users/be_groups with owner/member/reviewer. Mirror with a workspace_user pivot and a policy; owners can freeze/delete a workspace, members cannot.workspace_id = 0 rows over an API. Do not join against t3ver_* tables from Laravel — the semantics are TYPO3-version-specific.Integration tips: keep the versionable trait opt-in per model; add a php artisan workspace:preview {id} command for local QA; and build a Workspace Filament/Livewire resource early — TYPO3's backend module is the UX template (list workspaces → list changes → diff → publish/discard).
Classes/ code into a proprietary Laravel app is a distribution problem. Reimplement the model, don't vendor the code.^13.4, ^14), and breaking changes ship as RST changelog entries, not semver. Pin TYPO3 itself.t3ver_state distinguishes a new record, an edited record, a deleted record, and a move placeholder. Moves especially need a placeholder row in the source position or pagination silently drops items. Budget real time for delete/move semantics; they are where naive implementations break.sys_refindex and swaps them on publish. In Laravel, decide up front whether a draft relates to draft children (correct, expensive) or to live children (cheap, wrong for nested content). Don't defer this decision to week three.TcaSchemaFactory instead of $GLOBALS['TCA'], use #[AsEventListener] instead of hooks, constructor injection instead of GeneralUtility::makeInstance(), and give every exception a unique integer code (Build/Scripts/runTests.sh -s listExceptionCodes enforces it). Frontend assets under Resources/Public/{JavaScript,Css} are build output — never edit them.importCSVDataSet(), which does not resolve EXT: paths — use __DIR__-relative). For your Laravel port, the equivalent discipline is a fixture workspace + a test that asserts a draft is invisible on live, visible in preview, and swapped on publish. Write that test before the second model adopts the trait.How can I help you explore Laravel packages today?