From craft-workspace-webconsulting-skills
Provides expert guidance on TYPO3 Workspaces for versioning, staging, publishing workflows, file limitations, collections safety, query migration, debugging, and testing. Useful for draft content review and workflows.
npx claudepluginhub dirnbauer/webconsulting-skillsThis skill uses the workspace's default tool permissions.
> **Compatibility:** TYPO3 v14.x
Guides TYPO3 DataHandler usage for creating, updating, deleting, copying, and moving TCA records like pages and tt_content, ensuring refindex integrity, hooks, and transactional safety. Use for database record ops.
Implements content versioning for headless CMS using C# models: draft/publish workflows, history tables, snapshots, rollback, audit trails, and event sourcing.
Guides Next.js Cache Components and Partial Prerendering (PPR) with cacheComponents enabled. Implements 'use cache', cacheLife(), cacheTag(), revalidateTag(), static/dynamic optimization, and cache debugging.
Share bugs, ideas, or general feedback.
Compatibility: TYPO3 v14.x All code examples in this skill are designed to work on TYPO3 v14.
TYPO3 API First: Always use TYPO3's built-in APIs, core features, and established conventions before creating custom implementations. Do not reinvent what TYPO3 already provides. Always verify that the APIs and methods you use exist and are not deprecated in TYPO3 v14 by checking the official TYPO3 documentation.
This skill is based on 17 authoritative sources:
Workspaces allow editors to prepare content changes without affecting the live website. Changes go through a configurable review process before publication.
There are two types:
Offline (workspace) versions live in the same database table as live records. They are identified by:
| Field | Purpose |
|---|---|
t3ver_oid | Points to the live record's uid (0 for live records and workspace-new records) |
t3ver_wsid | Workspace ID this version belongs to (0 for live) |
t3ver_state | Special state flags (see below) |
t3ver_stage | Workflow stage (0=editing, -10=ready to publish) |
pid | Real page ID (same as the live record's pid) |
Note: Before TYPO3 v11, offline versions had
pid = -1. Since v11 (Breaking #92497), workspace records store their real pid. If you encounterpid = -1in legacy code or documentation, it is outdated.
t3ver_state values (TYPO3 v14):
| Value | Meaning |
|---|---|
0 | Default: workspace modification of existing live record |
1 | New record created exclusively in workspace (no live counterpart) |
2 | Delete placeholder (record marked for deletion upon publish) |
4 | Move pointer (record to be moved upon publish, stores new pid/sorting) |
Removed in v11:
t3ver_state = -1(old "new version" pendant),t3ver_state = 3(old "move placeholder"), and thet3ver_move_idfield. If you see these values in legacy code, they are not used on TYPO3 v14.
TYPO3 always fetches live records first, then overlays workspace versions on top. For translations with workspaces, the chain is:
t3ver_oid=<uid> AND t3ver_wsid=<current_ws>)l10n_parent=<uid> in target language)The uid of the live record is always preserved during overlay -- this keeps all references and links intact.
version commands with action => 'publish' (preferred) or action => 'swap' (alias handled the same as publish in DataHandlerHook). The parameter key for the workspace version UID remains swapWith, not uid.sys_workspace (removed in v11, #92206) — that is separate from the swap keyword in cmdmaps.Files (FAL) are NOT versioned. This is the single most important limitation of TYPO3 Workspaces. See also Section 2a below for the additional file collection limitation (folder-based collections).
fileadmin/ live exclusively in the LIVE workspacesys_file_reference records are workspace-versioned; physical files and sys_file records are not versioned like content overlaysScenario: An editor creates a workspace version of a page replacing geschaeftsbericht2024.pdf with geschaeftsbericht2025.pdf. The workspace is NOT published yet. However:
fileadmin/ immediately (files are live!)geschaeftsbericht2025.pdf is accessible before the content element referencing it is publishedThis is a real security/confidentiality risk.
1. Use non-guessable filenames:
# Instead of:
fileadmin/reports/geschaeftsbericht2025.pdf
# Use hashed/random names:
fileadmin/reports/gb-a8f3e2b1c9d4.pdf
2. Store confidential files outside the web root:
// config/system/settings.php
// Use a private storage that is NOT publicly accessible
// Deliver files programmatically via a controller
3. Use EXT:secure_downloads (leuchtfeuer/secure-downloads):
Files are delivered through a PHP script that checks access permissions. No direct file URL access.
4. Server-level protection for sensitive directories:
# Apache (.htaccess in a subdirectory)
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
# NGINX
location /fileadmin/confidential/ {
deny all;
return 403;
}
5. Use separate file references per workspace version:
Upload the new file with a different name. Do NOT overwrite the existing file. The workspace version of the content element references the new file, the live version keeps the old one. Upon publishing, both files exist but only the new one is referenced.
fileadmin/ for files with predictable naming patternsFile collections (
sys_file_collection) are workspace-versioned at the record level, but folder-based collections resolve files from the live filesystem at runtime -- there is no database relation to overlay.
sys_file_collection stores three collection types, distinguished by the type field:
type value | Resolution mechanism | Database relations involved |
|---|---|---|
static (0) | sys_file_reference rows (tablenames='sys_file_collection', fieldname='files') | One sys_file_reference row per file |
folder (1) | folder_identifier string (e.g. 1:/user_upload/gallery/) | None -- no join table, no reference rows |
category (2) | category uid resolved via sys_category_record_mm | MM table (handled via parent record overlay) |
The TCA for sys_file_collection has versioningWS => true. This means the collection record itself gets workspace versions with t3ver_* fields. However, the record-level versioning only versions the fields stored in sys_file_collection -- it does not version the files that the collection resolves.
| Collection Type | Record versioned? | File binding versioned? | Physical files versioned? | Workspace-safe? |
|---|---|---|---|---|
| Static | Yes | Yes (sys_file_reference has versioningWS = true) | No | Partially safe |
| Folder-based | Yes (record only) | No -- no DB binding exists | No | Not safe |
| Category-based | Yes | Partially (category MM handled via parent overlay) | No | Partially safe |
For folder-based collections, the "relation" between the collection and its files is not a database relation. The collection record stores only a folder_identifier string. When FolderBasedFileCollection::loadContents() is called, TYPO3 calls $storage->getFilesInFolder() to list files directly from the live storage driver. There is no overlay-capable database row between the collection and its files.
Consequences:
folder_identifier field in a workspace version points to a different folder, but the folder contents themselves are always liveCType=uploads) using a folder-based collection will show different files than intended during workspace preview if the folder contents changed after the workspace version was createdStatic collections (type=static) bind files via sys_file_reference rows. Both sys_file_collection and sys_file_reference have versioningWS = true. When you add or remove a file from a static collection inside a workspace, TYPO3 creates workspace versions of the sys_file_reference rows. The binding between collection and file is versioned.
The remaining limitation: physical files (sys_file) are still not versioned (see Section 2). If you overwrite a file, it changes everywhere.
1. Prefer static collections when workspace isolation matters:
Use type=static instead of type=folder. The file-to-collection binding is a sys_file_reference row, which is workspace-versioned.
2. Use separate folders per workspace version:
For folder-based collections, create a new folder for the workspace change (e.g. gallery-v2/) and change the folder_identifier in the workspace version of the collection record. The live version still points to the original folder.
3. Do NOT modify shared folder contents while workspace drafts reference them:
If a folder-based collection is used in workspace content, avoid adding or removing files from that folder until the workspace is published.
4. Clean up old folders after publishing:
Use AfterRecordPublishedEvent to remove obsolete folders after the workspace version is published:
<?php
declare(strict_types=1);
namespace MyVendor\MyExtension\EventListener;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Resource\StorageRepository;
use TYPO3\CMS\Workspaces\Event\AfterRecordPublishedEvent;
#[AsEventListener]
final class CleanupOldCollectionFolderListener
{
public function __construct(
private readonly StorageRepository $storageRepository,
) {}
public function __invoke(AfterRecordPublishedEvent $event): void
{
if ($event->getTable() !== 'sys_file_collection') {
return;
}
// After publishing a folder-based collection, remove the old folder
// if it is no longer referenced by any collection.
// Implementation depends on your naming convention (e.g. gallery-v1/, gallery-v2/).
}
}
type=static) over folder-based when workspaces are used# Composer (TYPO3 v14)
composer require typo3/cms-workspaces
# Non-Composer: activate in Admin Tools > Extensions
Use this checklist to verify your workspace setup is complete:
typo3/cms-workspaces is installed and activatedbin/typo3 extension:setup or your project's equivalent schema-update wrapper)typo3/cms-scheduler is installed (required for auto-publish)tables_modify permissions for all relevant tables'versioningWS' => true in $GLOBALS['TCA'][<table>]['ctrl']'versioningWS' => false'versioningWS_alwaysAllowLiveEdit' => truet3ver_* database columns exist (auto-created when versioningWS = true)Inline / IRRE child tables (Deprecation #106821): If
versioningWSis missing on a child table, Core logs a deprecation and may auto-add the flag at TCA build time — it does not throw a hard error. Set the flag explicitly in TCA; it will become a hard requirement in v15.
options.workspaces.previewLinkTTLHours set (default: 48)options.workspaces.allowed_languages.<wsId> set if language restrictions neededworkspaces.splitPreviewModes configured if preview modes should be limitedoptions.workspaces.previewPageId set for custom record preview pages<?php
declare(strict_types=1);
namespace MyVendor\MySitepackage\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* CLI command to create a workspace with base configuration.
*
* With `autoconfigure: true` in `Services.yaml`, `#[AsCommand]` is enough — no `console.command` tag needed.
*/
#[AsCommand(
name: 'workspace:setup',
description: 'Create a workspace with backend group and base configuration',
)]
final class SetupWorkspaceCommand extends Command
{
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
// Step 1: Create backend user group for workspace editors
$data = [];
$data['be_groups']['NEW_ws_group'] = [
'pid' => 0,
'title' => 'Workspace Editors',
'description' => 'Backend group for workspace editing access',
// Grant access to common tables
'tables_modify' => 'pages,tt_content,sys_file_reference',
'tables_select' => 'pages,tt_content,sys_file_reference,sys_file',
// Grant LIVE workspace access
'workspace_perms' => 1,
// Page types allowed
'pagetypes_select' => '1,3,4,6,7,199,254',
// Explicitly allow content types
'explicit_allowdeny' => '',
];
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($data, []);
$dataHandler->process_datamap();
if (!empty($dataHandler->errorLog)) {
$io->error('Failed to create backend group: ' . implode(', ', $dataHandler->errorLog));
return Command::FAILURE;
}
$groupUid = $dataHandler->substNEWwithIDs['NEW_ws_group'] ?? 0;
$io->success('Created backend group "Workspace Editors" (uid=' . $groupUid . ')');
// Step 2: Create the custom workspace
$data = [];
$data['sys_workspace']['NEW_workspace'] = [
'pid' => 0,
'title' => 'Staging Workspace',
'description' => 'Content staging workspace for preview and review before publishing',
'adminusers' => 'be_groups_' . $groupUid,
'members' => 'be_groups_' . $groupUid,
// Stages: default stages (Editing, Ready to publish) are always available
// Publish access: 0 = no restriction, 1 = only publish-stage content, 2 = only owners can publish
'publish_access' => 0,
// Allow editing of non-versionable records (live edit)
// Field name matches Core TCA / DB column (historical spelling "notificaton")
'edit_allow_notificaton_settings' => 0,
];
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($data, []);
$dataHandler->process_datamap();
if (!empty($dataHandler->errorLog)) {
$io->error('Failed to create workspace: ' . implode(', ', $dataHandler->errorLog));
return Command::FAILURE;
}
$wsUid = $dataHandler->substNEWwithIDs['NEW_workspace'] ?? 0;
$io->success('Created workspace "Staging Workspace" (uid=' . $wsUid . ')');
$io->section('Next Steps');
$io->listing([
'Assign backend users to the "Workspace Editors" group',
'Configure DB mounts and file mounts on the group',
'Set up Scheduler tasks: "Workspaces auto-publication" + "Workspaces cleanup preview links"',
'Configure TSconfig: options.workspaces.previewLinkTTLHours = 48',
'Test: switch to the workspace in the backend sidebar and edit a page',
]);
return Command::SUCCESS;
}
}
Run:
# DDEV
ddev exec bin/typo3 workspace:setup
# Non-DDEV
bin/typo3 workspace:setup
WARNING: These SQL queries are for LOCAL DDEV development environments ONLY. NEVER run raw SQL on production. Use the DataHandler CLI command above instead. Raw SQL bypasses DataHandler, reference index, history, and cache clearing.
-- ============================================================
-- LOCAL DDEV ONLY - Workspace base configuration
-- Save this block as setup-workspace.sql, then run: ddev mysql < setup-workspace.sql
-- ============================================================
-- 1. Create backend user group for workspace editors
INSERT INTO be_groups (pid, title, description, workspace_perms, tables_modify, tables_select, pagetypes_select, tstamp, crdate)
VALUES (
0,
'Workspace Editors',
'Backend group for workspace editing access',
1, -- 1 = access to LIVE workspace
'pages,tt_content,sys_file_reference',
'pages,tt_content,sys_file_reference,sys_file',
'1,3,4,6,7,199,254',
UNIX_TIMESTAMP(),
UNIX_TIMESTAMP()
);
SET @group_uid = LAST_INSERT_ID();
-- 2. Create custom workspace
INSERT INTO sys_workspace (pid, title, description, adminusers, members, publish_access, tstamp, crdate)
VALUES (
0,
'Staging Workspace',
'Content staging workspace for preview and review before publishing',
CONCAT('be_groups_', @group_uid),
CONCAT('be_groups_', @group_uid),
0, -- 0 = no publish restriction
UNIX_TIMESTAMP(),
UNIX_TIMESTAMP()
);
SET @ws_uid = LAST_INSERT_ID();
-- 3. Verify
SELECT 'Backend Group' AS type, @group_uid AS uid, 'Workspace Editors' AS title
UNION ALL
SELECT 'Workspace' AS type, @ws_uid AS uid, 'Staging Workspace' AS title;
-- 4. Check existing workspace-enabled tables
SELECT TABLE_NAME
FROM information_schema.COLUMNS
WHERE COLUMN_NAME = 't3ver_oid'
AND TABLE_SCHEMA = DATABASE()
ORDER BY TABLE_NAME;
# Run in DDEV:
ddev mysql < setup-workspace.sql
<?php
// EXT:my_extension/Configuration/TCA/tx_myextension_domain_model_item.php
return [
'ctrl' => [
'title' => 'LLL:EXT:my_extension/Resources/Private/Language/locallang_db.xlf:tx_myextension_domain_model_item',
'label' => 'title',
'tstamp' => 'tstamp',
'crdate' => 'crdate',
'delete' => 'deleted',
'sortby' => 'sorting',
// Enable workspace versioning
'versioningWS' => true,
// Language support
'languageField' => 'sys_language_uid',
'transOrigPointerField' => 'l10n_parent',
'transOrigDiffSourceField' => 'l10n_diffsource',
'translationSource' => 'l10n_source',
'enablecolumns' => [
'disabled' => 'hidden',
'starttime' => 'starttime',
'endtime' => 'endtime',
],
'iconfile' => 'EXT:my_extension/Resources/Public/Icons/item.svg',
],
// ... columns, types, palettes
];
The t3ver_* database columns are auto-created when versioningWS = true -- you do NOT need to add them to ext_tables.sql. Similarly, enablecolumns fields (hidden, starttime, endtime) and language fields (sys_language_uid, l10n_parent, l10n_diffsource) get auto-created TCA column definitions from ctrl on TYPO3 v14 -- you do NOT need to define them in 'columns'.
After enabling, run:
bin/typo3 extension:setup
If a table must NOT be versioned (e.g., logging, statistics):
<?php
// EXT:my_extension/Configuration/TCA/Overrides/tx_myextension_log.php
$GLOBALS['TCA']['tx_myextension_log']['ctrl']['versioningWS'] = false;
For tables that should always be edited live (e.g., user settings, configuration):
<?php
// EXT:my_extension/Configuration/TCA/Overrides/tx_myextension_settings.php
// Records of this table are edited live even when a workspace is active
$GLOBALS['TCA']['tx_myextension_settings']['ctrl']['versioningWS_alwaysAllowLiveEdit'] = true;
<?php
declare(strict_types=1);
namespace MyVendor\MyExtension\Controller;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
final class BackendItemController
{
public function __construct(
private readonly ConnectionPool $connectionPool,
) {}
/**
* Fetch a single record with workspace overlay.
*/
public function getItem(int $uid): ?array
{
// Option A: One-liner (recommended)
$row = BackendUtility::getRecordWSOL('tx_myextension_domain_model_item', $uid);
// Option B: Manual (equivalent to A)
// $row = BackendUtility::getRecord('tx_myextension_domain_model_item', $uid);
// BackendUtility::workspaceOL('tx_myextension_domain_model_item', $row);
return is_array($row) ? $row : null;
}
/**
* Fetch multiple records with workspace overlay.
*/
public function getItemsByPage(int $pageId): array
{
$queryBuilder = $this->connectionPool
->getQueryBuilderForTable('tx_myextension_domain_model_item');
$result = $queryBuilder
->select('*')
->from('tx_myextension_domain_model_item')
->where(
$queryBuilder->expr()->eq(
'pid',
$queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT)
)
)
->executeQuery();
$items = [];
while ($row = $result->fetchAssociative()) {
// CRITICAL: Apply workspace overlay to each row
BackendUtility::workspaceOL('tx_myextension_domain_model_item', $row);
if (is_array($row)) {
$items[] = $row;
}
}
return $items;
}
}
<?php
declare(strict_types=1);
namespace MyVendor\MyExtension\Service;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
final class ItemService
{
public function __construct(
private readonly ConnectionPool $connectionPool,
private readonly PageRepository $pageRepository,
) {}
/**
* Fetch items with workspace + language overlay for frontend rendering.
*/
public function findByPage(int $pageId): array
{
$queryBuilder = $this->connectionPool
->getQueryBuilderForTable('tx_myextension_domain_model_item');
$result = $queryBuilder
->select('*')
->from('tx_myextension_domain_model_item')
->where(
$queryBuilder->expr()->eq(
'pid',
$queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT)
)
)
->executeQuery();
$items = [];
while ($row = $result->fetchAssociative()) {
// Apply workspace overlay (MUST be called before language overlay)
$this->pageRepository->versionOL('tx_myextension_domain_model_item', $row);
if (!is_array($row)) {
continue;
}
// Apply language overlay
$row = $this->pageRepository->getLanguageOverlay(
'tx_myextension_domain_model_item',
$row
);
if (is_array($row)) {
$items[] = $row;
}
}
return $items;
}
}
<?php
// EXT:my_extension/Configuration/Backend/Modules.php
return [
'my_module' => [
// v14: primary web modules use parent `content`; compare with Core `Configuration/Backend/Modules.php`
'parent' => 'content',
'position' => ['after' => 'records'],
'access' => 'user',
// Control workspace availability:
// '*' = available in all workspaces (default)
// 'live' = only available in live workspace
// 'offline' = only available in custom workspaces
'workspaces' => '*',
'iconIdentifier' => 'my-module-icon',
'labels' => 'LLL:EXT:my_extension/Resources/Private/Language/locallang_mod.xlf',
'routes' => [
'_default' => [
'target' => \MyVendor\MyExtension\Controller\MyModuleController::class . '::handleRequest',
],
],
],
];
BEFORE (no workspace awareness):
<?php
// WRONG: Does not respect workspaces
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tt_content');
$rows = $queryBuilder
->select('*')
->from('tt_content')
->where(
$queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT))
)
->executeQuery()
->fetchAllAssociative();
// Rows may include workspace placeholders and miss workspace versions!
AFTER (workspace-aware):
<?php
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Database\Connection;
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tt_content');
$result = $queryBuilder
->select('*')
->from('tt_content')
->where(
$queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT))
)
->executeQuery();
$rows = [];
while ($row = $result->fetchAssociative()) {
// Apply workspace overlay
BackendUtility::workspaceOL('tt_content', $row);
if (is_array($row)) {
$rows[] = $row;
}
}
BEFORE (no workspace restriction):
<?php
// WRONG for frontend: Missing WorkspaceRestriction
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_news_domain_model_news');
$queryBuilder->getRestrictions()->removeAll()->add(
GeneralUtility::makeInstance(DeletedRestriction::class)
);
AFTER (proper frontend restrictions):
<?php
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
// FrontendRestrictionContainer includes:
// - DeletedRestriction
// - WorkspaceRestriction <-- automatically included!
// - HiddenRestriction
// - StartTimeRestriction
// - EndTimeRestriction
// - FrontendGroupRestriction
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_news_domain_model_news');
$queryBuilder->setRestrictions(
GeneralUtility::makeInstance(FrontendRestrictionContainer::class)
);
<?php
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Utility\GeneralUtility;
$workspaceId = GeneralUtility::makeInstance(Context::class)
->getPropertyFromAspect('workspace', 'id', 0);
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_myext_item');
$queryBuilder->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class))
->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $workspaceId));
Important:
WorkspaceRestrictiononly filters the SQL result to exclude wrong workspace records. You still must callversionOL()orworkspaceOL()on each row to get the actual workspace content overlaid.
Extbase repositories handle workspace overlays automatically via the persistence layer. No changes needed for standard findBy* methods.
CRITICAL: Extbase DataMapper does NOT populate
t3ver_*system fields. If you add$t3verWsid,$t3verState, or$t3verOidproperties to an Extbase domain model, they will always remain at their default value (typically0). Thet3ver_*columns arectrlfields in TCA, notcolumns— Extbase'sDataMapFactorydoes not create column maps for them.To inspect workspace state of Extbase model objects, query the database directly via
ConnectionPool:$row = GeneralUtility::makeInstance(ConnectionPool::class) ->getConnectionForTable('pages') ->select(['t3ver_wsid', 't3ver_state', 't3ver_oid'], 'pages', ['uid' => $post->getUid()]) ->fetchAssociative();
However, if you use custom QueryBuilder inside a repository:
<?php
declare(strict_types=1);
namespace MyVendor\MyExtension\Domain\Repository;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
use TYPO3\CMS\Extbase\Persistence\Repository;
final class ItemRepository extends Repository
{
public function __construct(
private readonly ConnectionPool $connectionPool,
private readonly PageRepository $pageRepository,
) {
parent::__construct();
}
/**
* Custom query that needs manual workspace handling.
*/
public function findItemsWithCustomQuery(int $categoryId): array
{
$queryBuilder = $this->connectionPool
->getQueryBuilderForTable('tx_myextension_domain_model_item');
// Use FrontendRestrictionContainer for proper workspace filtering
$queryBuilder->setRestrictions(
\TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(
\TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer::class
)
);
$result = $queryBuilder
->select('i.*')
->from('tx_myextension_domain_model_item', 'i')
->join(
'i',
'sys_category_record_mm',
'mm',
$queryBuilder->expr()->eq('mm.uid_foreign', $queryBuilder->quoteIdentifier('i.uid'))
)
->where(
$queryBuilder->expr()->eq(
'mm.uid_local',
$queryBuilder->createNamedParameter($categoryId, \TYPO3\CMS\Core\Database\Connection::PARAM_INT)
)
)
->executeQuery();
$items = [];
while ($row = $result->fetchAssociative()) {
$this->pageRepository->versionOL('tx_myextension_domain_model_item', $row);
if (is_array($row)) {
$items[] = $row;
}
}
return $items;
}
}
BEFORE (deprecated -- old exec_SELECTquery pattern, no longer available):
<?php
// DEPRECATED: Do NOT use. Not available on TYPO3 v14.
// $res = $GLOBALS['TYPO3_DB']->exec_SELECTquery('*', 'tt_content', 'pid=' . (int)$pageId);
AFTER (modern QueryBuilder with workspace support):
<?php
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tt_content');
$result = $queryBuilder
->select('*')
->from('tt_content')
->where(
$queryBuilder->expr()->eq(
'pid',
$queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT)
)
)
->executeQuery();
while ($row = $result->fetchAssociative()) {
BackendUtility::workspaceOL('tt_content', $row);
if (is_array($row)) {
// Process the workspace-overlaid record
}
}
When rendering a page in a workspace with a non-default language:
Live Record (lang=0, ws=0)
└─► Workspace Overlay (lang=0, ws=1) ← workspace version of default language
└─► Language Overlay (lang=1, ws=0) ← translation of live record
└─► Workspace Overlay (lang=1, ws=1) ← workspace version of translation
TYPO3 handles this automatically when using PageRepository->versionOL() and PageRepository->getLanguageOverlay().
When working in a workspace:
t3ver_state = 1 (workspace-new)l10n_parent field points to the live default language record UIDt3ver_oid pointing to the live translation UIDDatabase example for a tt_content record translated to French in workspace 1:
| uid | pid | t3ver_wsid | t3ver_oid | t3ver_state | l10n_parent | sys_language_uid | header |
|---|---|---|---|---|---|---|---|
| 11 | 20 | 0 | 0 | 0 | 0 | 0 | Article #1 |
| 31 | 20 | 1 | 0 | 1 | 11 | 1 | Article #1 (fr) |
Note: On TYPO3 v14, workspace-new records are a single row with
t3ver_state = 1and realpid. The old two-row pattern (placeholder + version withpid = -1andt3ver_state = -1) was removed in v11.
Restrict which languages a user can edit in a specific workspace:
# User TSconfig
# Allow only French (uid=1) and German (uid=2) in workspace 3
options.workspaces.allowed_languages.3 = 1,2
The same file limitation applies to translations:
sys_file_reference with sys_language_uid > 0) ARE versionedWhen workspaces "don't work", check these in order:
| # | Check | How |
|---|---|---|
| 1 | Extension installed? | composer show typo3/cms-workspaces |
| 2 | Workspace record exists? | List module on root page, filter System Records |
| 3 | User has workspace access? | Backend user/group > Mounts and Workspaces tab |
| 4 | User has LIVE access? | Same tab, "Live" checkbox must be checked |
| 5 | Table is versioningWS? | Check $GLOBALS['TCA'][<table>]['ctrl']['versioningWS'] |
| 6 | DB columns exist? | DESCRIBE <table> -- look for t3ver_oid, t3ver_wsid, t3ver_state |
| 7 | DB mounts correct? | User/group must have DB mounts covering the pages being edited |
| 8 | File mounts correct? | User/group must have file mounts for media access |
| 9 | Schema up to date? | bin/typo3 extension:setup (or your project's schema-update wrapper) |
| 10 | Cache cleared? | bin/typo3 cache:flush |
-- Check if workspace records exist
SELECT uid, title, adminusers, members, publish_access
FROM sys_workspace
WHERE deleted = 0;
-- Check versioned records for a specific page
SELECT uid, pid, t3ver_oid, t3ver_wsid, t3ver_state, t3ver_stage, header
FROM tt_content
WHERE t3ver_wsid > 0
AND deleted = 0
ORDER BY t3ver_wsid, t3ver_oid;
-- Find orphaned workspace records (pointing to deleted live records)
SELECT ws.uid AS ws_uid, ws.t3ver_oid, ws.t3ver_wsid, ws.header
FROM tt_content ws
LEFT JOIN tt_content live ON ws.t3ver_oid = live.uid
WHERE ws.t3ver_wsid > 0
AND ws.deleted = 0
AND (live.uid IS NULL OR live.deleted = 1);
-- Check workspace-enabled tables
SELECT TABLE_NAME
FROM information_schema.COLUMNS
WHERE COLUMN_NAME = 't3ver_oid'
AND TABLE_SCHEMA = DATABASE()
ORDER BY TABLE_NAME;
-- Inspect backend user's workspace permissions
SELECT
bu.uid,
bu.username,
bu.workspace_perms,
GROUP_CONCAT(bg.title SEPARATOR ', ') AS groups,
GROUP_CONCAT(bg.workspace_perms SEPARATOR ', ') AS group_ws_perms
FROM be_users bu
LEFT JOIN be_users_be_groups_mm mm ON bu.uid = mm.uid_local
LEFT JOIN be_groups bg ON mm.uid_foreign = bg.uid
WHERE bu.deleted = 0
AND bu.disable = 0
GROUP BY bu.uid, bu.username, bu.workspace_perms;
-- Check sys_log for workspace operations
SELECT
FROM_UNIXTIME(tstamp) AS time,
userid,
action,
details,
tablename,
recuid,
workspace
FROM sys_log
WHERE workspace > 0
ORDER BY tstamp DESC
LIMIT 50;
<?php
declare(strict_types=1);
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Utility\GeneralUtility;
// Get current workspace ID
$workspaceId = GeneralUtility::makeInstance(Context::class)
->getPropertyFromAspect('workspace', 'id', 0);
// Table modify permission (does not replace full workspace record ACL — use FormEngine/DataHandler for authoritative checks)
$mayModifyTable = $GLOBALS['BE_USER']->check('tables_modify', 'tt_content');
// Creating new records in workspace: BackendUserAuthentication API name is workspaceCanCreateNewRecord(string $table): bool (no $pageId)
$canCreate = $GLOBALS['BE_USER']->workspaceCanCreateNewRecord('tt_content');
// Workspace membership / role for the current user
$workspaceAccess = $GLOBALS['BE_USER']->checkWorkspace($workspaceId);
// Returns array|false — `_ACCESS` may be `admin`, `owner`, `member`, `online`, or `false` depending on context (see Core source for your minor)
| Issue | Cause | Solution |
|---|---|---|
| Records not visible in workspace | versioningWS = false on table | Set versioningWS = true, then run extension:setup (or your project's schema-update wrapper) |
| Workspace changes visible on live site | Missing WorkspaceRestriction in custom query | Add FrontendRestrictionContainer or manual WorkspaceRestriction |
| "Editing not possible" error | User lacks edit permission or stage access | Check user/group tables_modify, DB mounts, workspace membership |
| Preview shows wrong content | Missing versionOL() call in extension | Add BackendUtility::workspaceOL() or PageRepository->versionOL() after query |
| Publish does nothing | Content not in "Ready to publish" stage | Advance content through stages, or disable stage restriction |
| File changed in all workspaces | Files are not versioned (by design) | Upload new files with unique names instead of overwriting |
| Translation missing in workspace | Language not allowed in workspace | Set options.workspaces.allowed_languages.<wsId> TSconfig |
| Auto-publish not working | Scheduler task not configured or not running | Create "Workspaces auto-publication" task, verify cron job |
| Workspace selector not visible | User has no workspace access | Assign user/group as workspace member or owner |
Step 1: Require dev dependencies
# DDEV
ddev composer require --dev typo3/testing-framework:"^9.0" phpunit/phpunit:"^11.0"
# Non-DDEV
composer require --dev typo3/testing-framework:"^9.0" phpunit/phpunit:"^11.0"
For TYPO3 v14 use
typo3/testing-framework:"^9.0"(seetypo3-testingskill for details).
Step 2: Create PHPUnit configuration for functional tests
Create Build/phpunit-functional.xml in your extension root:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/typo3/testing-framework/Resources/Core/Build/FunctionalTestsBootstrap.php"
colors="true"
cacheResult="false">
<testsuites>
<testsuite name="Functional">
<directory>Tests/Functional</directory>
</testsuite>
</testsuites>
<php>
<!-- Functional tests need a database. DDEV provides one automatically. -->
<!-- For non-DDEV: set typo3DatabaseHost, typo3DatabaseUsername, etc. -->
<!-- Example for DDEV (auto-detected, usually no env vars needed): -->
<!-- <env name="typo3DatabaseHost" value="db"/> -->
<!-- <env name="typo3DatabaseUsername" value="db"/> -->
<!-- <env name="typo3DatabasePassword" value="db"/> -->
<!-- <env name="typo3DatabaseName" value="db"/> -->
</php>
</phpunit>
Step 3: Create directory structure
mkdir -p Tests/Functional/Fixtures
your-extension/
├── Build/
│ └── phpunit-functional.xml ← PHPUnit config
├── Classes/
│ └── ...
├── Configuration/
│ └── TCA/
├── Tests/
│ └── Functional/
│ ├── Fixtures/
│ │ └── WorkspaceTestData.csv ← Test data
│ └── WorkspaceAwareTest.php ← Test class
├── composer.json
└── ext_emconf.php
Step 4: Ensure composer.json has autoload-dev
{
"autoload-dev": {
"psr-4": {
"MyVendor\\MyExtension\\Tests\\": "Tests/"
}
}
}
Then run:
# DDEV
ddev composer dump-autoload
# Non-DDEV
composer dump-autoload
<?php
declare(strict_types=1);
namespace MyVendor\MyExtension\Tests\Functional;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Context\WorkspaceAspect;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
final class WorkspaceAwareTest extends FunctionalTestCase
{
/**
* Load the workspaces extension for all tests in this class.
*/
protected array $coreExtensionsToLoad = [
'workspaces',
];
/**
* Load your own extension.
*/
protected array $testExtensionsToLoad = [
'typo3conf/ext/my_extension',
];
protected function setUp(): void
{
parent::setUp();
// Import base data: pages, content, workspace record, backend user
$this->importCSVDataSet(__DIR__ . '/Fixtures/WorkspaceTestData.csv');
// Initialize backend user (uid=1 from fixture, must be admin for DataHandler)
$this->setUpBackendUser(1);
Bootstrap::initializeLanguageObject();
}
/**
* Helper: set the current workspace context.
*/
private function setWorkspaceId(int $workspaceId): void
{
$context = GeneralUtility::makeInstance(Context::class);
$context->setAspect('workspace', new WorkspaceAspect($workspaceId));
$GLOBALS['BE_USER']->setWorkspace($workspaceId);
}
/**
* Test: Record created in workspace is NOT visible in live.
*/
public function testRecordCreatedInWorkspaceNotVisibleInLive(): void
{
// Switch to workspace 1
$this->setWorkspaceId(1);
// Create a record in the workspace via DataHandler
$data = [
'tt_content' => [
'NEW_1' => [
'pid' => 1,
'CType' => 'text',
'header' => 'Workspace Only Content',
'bodytext' => '<p>This should not be live</p>',
],
],
];
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($data, []);
$dataHandler->process_datamap();
self::assertEmpty($dataHandler->errorLog, 'DataHandler errors: ' . implode(', ', $dataHandler->errorLog));
// Switch back to LIVE
$this->setWorkspaceId(0);
// Query live records -- the workspace record should NOT appear
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tt_content');
$liveRecords = $queryBuilder
->select('uid', 'header')
->from('tt_content')
->where(
$queryBuilder->expr()->eq('pid', 1),
$queryBuilder->expr()->eq('t3ver_wsid', 0),
$queryBuilder->expr()->neq('t3ver_state', 1) // exclude new placeholders
)
->executeQuery()
->fetchAllAssociative();
$headers = array_column($liveRecords, 'header');
self::assertNotContains('Workspace Only Content', $headers);
}
/**
* Test: Workspace overlay returns modified content.
*/
public function testWorkspaceOverlayReturnsModifiedContent(): void
{
// Record uid=10 exists in fixture with header "Original Header"
// Workspace version exists with header "Modified In Workspace"
$this->setWorkspaceId(1);
$row = \TYPO3\CMS\Backend\Utility\BackendUtility::getRecordWSOL('tt_content', 10);
self::assertIsArray($row);
self::assertSame('Modified In Workspace', $row['header']);
}
/**
* Test: Workspace publish command makes staged content live for a record pair.
*
* Publish live tt_content uid=10 using the workspace version row (swapWith = offline version uid).
*/
public function testPublishWorkspaceRecordPairMakesContentLive(): void
{
$this->setWorkspaceId(1);
// Publish workspace record for tt_content uid=10
$cmd = [
'tt_content' => [
10 => [
'version' => [
'action' => 'publish',
'swapWith' => $this->getWorkspaceVersionUid('tt_content', 10, 1),
],
],
],
];
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([], $cmd);
$dataHandler->process_cmdmap();
self::assertEmpty($dataHandler->errorLog);
// Switch to LIVE and verify
$this->setWorkspaceId(0);
$row = \TYPO3\CMS\Backend\Utility\BackendUtility::getRecord('tt_content', 10);
self::assertSame('Modified In Workspace', $row['header']);
}
/**
* Helper: Get the uid of the workspace version of a record.
*/
private function getWorkspaceVersionUid(string $table, int $liveUid, int $workspaceId): int
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable($table);
$queryBuilder->getRestrictions()->removeAll();
$row = $queryBuilder
->select('uid')
->from($table)
->where(
$queryBuilder->expr()->eq('t3ver_oid', $liveUid),
$queryBuilder->expr()->eq('t3ver_wsid', $workspaceId),
$queryBuilder->expr()->eq('deleted', 0)
)
->executeQuery()
->fetchAssociative();
return (int)($row['uid'] ?? 0);
}
}
Create Tests/Functional/Fixtures/WorkspaceTestData.csv:
"be_users"
,"uid","pid","username","password","admin","workspace_perms"
,1,0,"admin","$2y$12$placeholder",1,1
"sys_workspace"
,"uid","pid","title","adminusers","members","deleted"
,1,0,"Test Workspace","1","1",0
"pages"
,"uid","pid","title","slug","deleted","t3ver_oid","t3ver_wsid","t3ver_state"
,1,0,"Test Page","/test-page",0,0,0,0
"tt_content"
,"uid","pid","header","CType","bodytext","deleted","t3ver_oid","t3ver_wsid","t3ver_state"
,10,1,"Original Header","text","<p>Original</p>",0,0,0,0
,11,1,"Modified In Workspace","text","<p>Modified</p>",0,10,1,0
DDEV (recommended):
# Run all workspace functional tests
ddev exec bin/phpunit -c Build/phpunit-functional.xml \
Tests/Functional/WorkspaceAwareTest.php
# Run a single test method
ddev exec bin/phpunit -c Build/phpunit-functional.xml \
--filter testWorkspaceOverlayReturnsModifiedContent \
Tests/Functional/WorkspaceAwareTest.php
# Verbose output (shows each test name)
ddev exec bin/phpunit -c Build/phpunit-functional.xml \
-v Tests/Functional/WorkspaceAwareTest.php
DDEV auto-provides the test database. No extra env vars needed.
Non-DDEV (manual database config):
# Set database credentials for the test runner
export typo3DatabaseHost="127.0.0.1"
export typo3DatabasePort="3306"
export typo3DatabaseUsername="root"
export typo3DatabasePassword="root"
export typo3DatabaseName="typo3_test"
bin/phpunit -c Build/phpunit-functional.xml \
Tests/Functional/WorkspaceAwareTest.php
The testing framework creates a temporary database per test case. Your env vars point to the DB server -- the framework handles the rest.
Troubleshooting test failures:
| Error | Cause | Fix |
|---|---|---|
Table 'sys_workspace' doesn't exist | workspaces not in $coreExtensionsToLoad | Add 'workspaces' to array |
Table 'be_users' has no column 'workspace_perms' | Schema not created for test DB | Ensure workspaces is loaded before setUp() |
Access denied for user | Wrong DB credentials | Check env vars or DDEV status (ddev describe) |
Call to undefined method setUpBackendUser | Wrong testing-framework version | Use typo3/testing-framework ^9.0 on TYPO3 v14 |
Record not found after DataHandler | CSV fixture malformed | Verify CSV: first row is table name, second row is column headers, data rows start with comma |
Key points:
pages and tt_content have versioningWS = true by defaultsys_file_reference overlays (physical files are not versioned); MM relations (categories) are handled through parent record overlays/DataHandler relation handling; simple fields (links, text) are versioned directly in the record overlaytx_news_domain_model_news has versioningWS = true by default. Workspace workflows work for news records.
Watch out for:
sys_category): versioned by default, workstx_news_domain_model_tag): check if versioningWS is enabled# Page TSconfig for news preview in workspace
options.workspaces.previewPageId.tx_news_domain_model_news = 42
For temporary content (Christmas, Black Friday, product launches):
# User TSconfig -- set preview link expiry
options.workspaces.previewLinkTTLHours = 72
Generate via Workspaces module: "Generate page preview links" button. The link works without any TYPO3 backend access.
# Cron job (runs every 15 minutes)
*/15 * * * * /path/to/bin/typo3 scheduler:run
Fired after a record has been published from a workspace to live.
<?php
declare(strict_types=1);
namespace MyVendor\MyExtension\EventListener;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Workspaces\Event\AfterRecordPublishedEvent;
#[AsEventListener]
final class AfterPublishListener
{
public function __invoke(AfterRecordPublishedEvent $event): void
{
$table = $event->getTable();
$liveId = $event->getRecordId();
// Example: Clear external CDN cache after publishing
if ($table === 'pages') {
// Trigger CDN purge for the published page
}
// Example: Notify external system
if ($table === 'tx_news_domain_model_news') {
// Send webhook to newsletter system
}
}
}
Fired after sorting data in the Workspaces backend module. Use to apply custom sorting.
<?php
declare(strict_types=1);
namespace MyVendor\MyExtension\EventListener;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Workspaces\Event\SortVersionedDataEvent;
#[AsEventListener]
final class CustomWorkspaceSortListener
{
public function __invoke(SortVersionedDataEvent $event): void
{
// Custom sorting logic for workspace module
$data = $event->getData();
// ... modify $data ...
$event->setData($data);
}
}
| Event | When Fired |
|---|---|
AfterCompiledCacheableDataForWorkspaceEvent | After compiling cacheable workspace version data |
AfterDataGeneratedForWorkspaceEvent | After generating all workspace version data |
AfterRecordPublishedEvent | After a record is published to live |
GetVersionedDataEvent | After preparing/cleaning workspace version data |
IsReferenceConsideredForDependencyEvent | When evaluating whether a reference is considered a workspace dependency |
ModifyVersionDifferencesEvent | When computing diffs between live and workspace version |
RetrievedPreviewUrlEvent | When adjusting the preview URL for a workspace record |
SortVersionedDataEvent | After sorting workspace version data in the module |
All events are in the \TYPO3\CMS\Workspaces\Event\ namespace.
The following workspace changes apply exclusively to TYPO3 v14.
Workspace "Freeze Editing" feature has been removed (#107323). Workspaces can no longer be frozen to prevent editing. Remove any code that references freezeEditingWorkspace or relies on frozen workspace state.
All inline (IRRE) child tables used in workspace-enabled parent tables should have 'versioningWS' => true in their TCA ctrl (#106821). On TYPO3 v14, a missing flag triggers a deprecation and Core may auto-add it at TCA compile time — set the flag explicitly in extensions now; it becomes a hard requirement in v15.
The workspace selector has been moved from the top bar to the backend sidebar. Workspaces now support color and description fields for visual distinction in the sidebar selector. Update any documentation or screenshots referencing the top bar workspace switcher.
The workspace "Publish" module now shows editor information (#106074) — which backend user last modified each record. This helps reviewers identify who made changes.
swap vs publish in cmdmaps [v14]DataHandlerHook treats swap and publish as aliases — both call version_swap(). Prefer action => 'publish' in new code. The workspace version UID must always be passed as swapWith (Core reads (int)$value['swapWith']):
// ✅ Always pass the offline/workspace version uid as swapWith (live record id is the cmdmap key)
$cmd['tt_content'][10]['version'] = [
'action' => 'publish',
'swapWith' => $workspaceVersionUid,
];
// 'swap' still works as an alias but publish is clearer
$cmd['tt_content'][10]['version'] = ['action' => 'swap', 'swapWith' => $workspaceVersionUid];
This skill is based on the official TYPO3 CMS documentation and community resources. Special thanks to the TYPO3 Core Team and b13 GmbH (Benni Mack) for their excellent explanation of the overlay mechanism.