typo3-workspaces

安装量: 34
排名: #19992

安装

npx skills add https://github.com/dirnbauer/webconsulting-skills --skill typo3-workspaces
TYPO3 Workspaces
Compatibility:
TYPO3 v13.x and v14.x (v14 preferred)
All code examples in this skill are designed to work on both TYPO3 v13 and 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 your target TYPO3 version (v13 or v14) by checking the official TYPO3 documentation.
Sources
This skill is based on 18 authoritative sources:
TYPO3 Workspaces Extension Docs
Versioning (Workspaces Extension)
Creating a Custom Workspace
Configuration Options (Workspaces)
PSR-14 Events (Workspaces)
Versioning & Workspaces (TYPO3 Explained / Core API)
TCA versioningWS Reference
Restriction Builder (TYPO3 Explained)
b13 Blog: The Elegant Efficiency of TYPO3 Overlays (Benni Mack)
Scheduler Tasks (Workspaces)
Users Guide (Workspaces)
TYPO3-CORE-SA-2025-022: Information Disclosure in Workspaces Module
Forge Bug #88021: FAL preview fails when file changed in workspace
Localized Content Guide
File Collections (TYPO3 Explained)
FAL Database Architecture
Forge Bug #60343: sys_file_metadata does not recognize workspace
Forge Feature #97923: Combined folder_identifier field for sys_file_collection
1. Core Concepts
What Are Workspaces?
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:
LIVE workspace
(ID=0): The default state. Every change is immediately visible. Access must be
explicitly granted
to backend users/groups.
Custom workspaces
(ID>0): Safe editing environments. Changes are versioned, previewable, and go through stages before going live.
How Versioning Works (Database Level)
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)
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
Set to
-1
for all offline versions
t3ver_state values:
Value
Meaning
-1
New placeholder version (workspace pendant for new record)
0
Default: workspace modification of existing record
1
New placeholder (live pendant, insertion point for sorting)
2
Delete placeholder (record marked for deletion upon publish)
3
Move placeholder (live placeholder, hidden online, linked via
t3ver_move_id
)
4
Move pointer (workspace pendant of record to be moved)
The Overlay Mechanism
TYPO3
always fetches live records first
, then overlays workspace versions on top. For translations with workspaces, the chain is:
Fetch default language, live record (language=0, workspace=0)
Overlay workspace version (search for
t3ver_oid=
AND
t3ver_wsid=
)
Overlay language translation (search for
l10n_parent=
in target language)
Overlay workspace version of translation
The
uid
of the live record is
always preserved
during overlay -- this keeps all references and links intact.
Publishing Workflow
Publish
Draft content replaces live content through the workspace publish process.
TYPO3 v13/v14 use publish workflows; do not rely on legacy workspace-level swap mode.
IMPORTANT
The swap action is no longer allowed in TYPO3 v14. Use action => 'publish' instead of action => 'swap' . 2. CRITICAL: File/FAL Limitation 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). What This Means Files in fileadmin/ live exclusively in the LIVE workspace If you overwrite a file, the change affects ALL workspaces immediately sys_file_reference records are workspace-versioned; physical files and sys_file records are not versioned like content overlays Replacing an image in a workspace content element may fail in preview (Forge #88021) The Predictable Filename Security Problem Scenario: An editor creates a workspace version of a page replacing geschaeftsbericht2024.pdf with geschaeftsbericht2025.pdf . The workspace is NOT published yet. However: The new PDF is uploaded to fileadmin/ immediately (files are live!) An external person guesses the URL by incrementing the year geschaeftsbericht2025.pdf is accessible before the content element referencing it is published This is a real security/confidentiality risk . Workarounds 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)

Require all denied

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. Rules for Workspace-Safe File Handling NEVER overwrite files that are used in content elements across workspaces ALWAYS upload new files with unique names when preparing workspace content NEVER rely on "the page is not published yet" to protect file confidentiality CONSIDER EXT:secure_downloads for any confidential documents AUDIT fileadmin/ for files with predictable naming patterns 2a. File Collections and Workspaces File 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. How File Collections Work 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. Workspace Safety per Collection Type 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 Why Folder-Based Collections Break Workspace Isolation 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: Adding a file to the folder makes it visible in all workspaces immediately Removing a file from the folder removes it from all workspaces immediately Changing the folder_identifier field in a workspace version points to a different folder, but the folder contents themselves are always live The "File links" content element ( CType=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 created Static Collections Are Safer Static 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. Workarounds for Folder-Based Collections 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:

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/). } } Rules for Workspace-Safe File Collections PREFER static collections ( type=static ) over folder-based when workspaces are used NEVER add or remove files from a folder that is referenced by a folder-based collection in an unpublished workspace USE separate folders per workspace version if folder-based collections are required DOCUMENT for editors that folder-based collections show live folder contents regardless of workspace state TEST file collection behavior in workspace preview before relying on it for editorial workflows 3. Installation & Setup Checklist Installation # Composer (v13/v14) composer require typo3/cms-workspaces # Non-Composer: activate in Admin Tools > Extensions Complete Setup Checklist Use this checklist to verify your workspace setup is complete: Extension & System typo3/cms-workspaces is installed and activated Database schema is up to date ( bin/typo3 database:updateschema ) typo3/cms-scheduler is installed (required for auto-publish) Scheduler cron job is configured on server Backend User Groups Workspace-specific backend user group created (e.g., "Workspace Editors") Group has explicit LIVE workspace access (Mounts and Workspaces tab > "Live" checkbox) Group has access to the custom workspace (assigned as owner or member in workspace record) DB mounts are set correctly (pages the group can edit) File mounts are set correctly (directories the group can access) Group has tables_modify permissions for all relevant tables Group has permissions on page types the workspace will contain Backend Users Users are assigned to the workspace user group Users can see the workspace selector in the top bar Non-admin users have LIVE workspace access only if they need it Workspace Record Workspace record created as System Record on root page (pid=0) Title and description set Owners assigned (use groups, not individual users) Members assigned (use groups, not individual users) Custom stages created if needed (between "Editing" and "Ready to publish") Notification settings configured per stage Mountpoints set if workspace should be restricted to specific page trees Publish date set if auto-publish is desired "Publish access" configured (restrict publishing to owners if needed) Tables (TCA) All custom extension tables have 'versioningWS' => true in $GLOBALS['TCA'][]['ctrl'] Tables that must NOT be versioned have 'versioningWS' => false Tables requiring live-editing in workspace have 'versioningWS_alwaysAllowLiveEdit' => true t3ver_* database columns exist (auto-created when versioningWS = true ) Scheduler Tasks "Workspaces auto-publication" task created and enabled "Workspaces cleanup preview links" task created and enabled Cron frequency is appropriate (e.g., every 15 minutes) TSconfig options.workspaces.previewLinkTTLHours set (default: 48) options.workspaces.allowed_languages. set if language restrictions needed workspaces.splitPreviewModes configured if preview modes should be limited options.workspaces.previewPageId set for custom record preview pages 4. DataHandler Setup Script CLI Command: Create Workspace with DataHandler setDescription ( 'Create a workspace with backend group and base configuration' ) ; } 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) '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 top bar and edit a page' , ] ) ; return Command :: SUCCESS ; } } Register in Configuration/Services.yaml : services : MyVendor\MySitepackage\Command\SetupWorkspaceCommand : tags : - name : 'console.command' command : 'workspace:setup' description : 'Create workspace with base configuration' Run: # DDEV ddev exec bin/typo3 workspace:setup # Non-DDEV bin/typo3 workspace:setup SQL Setup (LOCAL DDEV ONLY) 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 5. Making Extensions Workspace-Compatible Step 1: Enable versioningWS in TCA [ '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 since v13.3 -- you do NOT need to define them in 'columns' . After enabling, run: bin/typo3 database:updateschema Step 2: Disable Versioning for Specific Tables If a table must NOT be versioned (e.g., logging, statistics): 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 ; } } Step 5: Frontend Overlay (REQUIRED for Frontend Plugins) 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 ; } } Step 6: Backend Module Workspace Restriction [ 'parent' => 'web' , 'position' => [ 'after' => 'web_info' ] , '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' , ] , ] , ] , ] ; 6. Migrating Queries: WITHOUT Workspaces to WITH Workspaces Pattern A: Backend QueryBuilder (Before/After) BEFORE (no workspace awareness): 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): 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 ; } } Pattern B: Frontend with WorkspaceRestriction BEFORE (no workspace restriction): connectionPool -> getQueryBuilderForTable ( 'tx_news_domain_model_news' ) ; $queryBuilder -> getRestrictions ( ) -> removeAll ( ) -> add ( GeneralUtility :: makeInstance ( DeletedRestriction :: class ) ) ; AFTER (proper frontend restrictions): connectionPool -> getQueryBuilderForTable ( 'tx_news_domain_model_news' ) ; $queryBuilder -> setRestrictions ( GeneralUtility :: makeInstance ( FrontendRestrictionContainer :: class ) ) ; Pattern C: Adding WorkspaceRestriction Manually 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: WorkspaceRestriction only filters the SQL result to exclude wrong workspace records. You still must call versionOL() or workspaceOL() on each row to get the actual workspace content overlaid. Pattern D: Extbase Repository (Mostly Transparent) Extbase repositories handle workspace overlays automatically via the persistence layer. No changes needed for standard findBy* methods. However , if you use custom QueryBuilder inside a repository: 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 ; } } Pattern E: Migrating Legacy Queries (TYPO3 v10/v11 style to v13/v14) BEFORE (deprecated -- old exec_SELECTquery pattern, no longer available): exec_SELECTquery('*', 'tt_content', 'pid=' . (int)$pageId); AFTER (modern QueryBuilder with workspace support): 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 } } 7. Translations with Workspaces How the Dual Overlay Works 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() . Creating Translations in Workspace Context When working in a workspace: The default language record is versioned first (if modified) Translations created in a workspace get their own placeholder + version records The l10n_parent field points to the live default language record UID Each translation version has its own t3ver_oid pointing to the live translation Database 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 Placeholder (fr) 32 -1 1 31 -1 11 1 Article #1 (fr) Language Restrictions per Workspace 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 File Translations with Workspaces The same file limitation applies to translations: Translated file references ( sys_file_reference with sys_language_uid > 0 ) ARE versioned Physical files are NOT versioned If a translation needs a different PDF/image, upload it as a new file with a unique name Do NOT overwrite files shared between language versions 8. Debugging & Troubleshooting Quick Diagnostic Checklist When 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'][
]['ctrl']['versioningWS'] 6 DB columns exist? DESCRIBE
-- 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 database:updateschema 10 Cache cleared? bin/typo3 cache:flush SQL Queries for Debugging (DDEV Local Only) -- 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 ; Programmatic Permission Check getPropertyFromAspect ( 'workspace' , 'id' , 0 ) ; // Check if user can edit in current workspace $cannotEdit = $GLOBALS [ 'BE_USER' ] -> workspaceCannotEditRecord ( 'tt_content' , $row ) ; if ( $cannotEdit ) { // $cannotEdit contains error message explaining why throw new \ RuntimeException ( 'Cannot edit: ' . $cannotEdit ) ; } // Check if new records can be created on a page $canCreate = $GLOBALS [ 'BE_USER' ] -> workspaceCreateNewRecord ( $pageId , 'tt_content' ) ; // Check user's workspace access level $workspaceAccess = $GLOBALS [ 'BE_USER' ] -> checkWorkspace ( $workspaceId ) ; // Returns: array with 'uid', '_ACCESS' key ('admin', 'owner', 'member', or false) Common Issues & Solutions Issue Cause Solution Records not visible in workspace versioningWS = false on table Set versioningWS = true , run database:updateschema 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. 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 9. Testing Workspace Support Prerequisites: Install Testing Framework 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" Adjust typo3/testing-framework version to match your TYPO3 version: TYPO3 v13: typo3/testing-framework:"^8.2 || ^9.0" TYPO3 v14: typo3/testing-framework:"^9.0" Step 2: Create PHPUnit configuration for functional tests Create Build/phpunit-functional.xml in your extension root:

< 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

</ 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 Functional Test Setup

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' => '

This should not be live

' , ] , ] , ] ; $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. * * DEPRECATED: The 'swap' action is no longer allowed in TYPO3 v14. * Use 'action' => 'publish' instead. The 'swapWith' key is replaced by 'uid' (workspace version uid). */ public function testPublishWorkspaceRecordPairMakesContentLive ( ) : void { $this -> setWorkspaceId ( 1 ) ; // Publish workspace record for tt_content uid=10 $cmd = [ 'tt_content' => [ 10 => [ 'version' => [ 'action' => 'publish' , 'uid' => $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 ) ; } } CSV Fixture File 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" , "

Original

" , 0 , 0 , 0 , 0 , 11 , -1 , "Modified In Workspace" , "text" , "

Modified

" , 0 , 10 , 1 , 0 Run Tests 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 ^8.2 (v13) or ^9.0 (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 10. Best Practices Pages with Content Elements (Standard Workflow) Editor switches to the custom workspace via the top bar selector Editor navigates to the page and edits content elements TYPO3 automatically creates workspace versions of modified records Editor previews via "View webpage" button (shows workspace version) Editor sends changes to "Ready to publish" stage Reviewer approves or sends back with comments Publisher publishes to live (or auto-publish via Scheduler) Key points: pages and tt_content have versioningWS = true by default FAL relations (images, media) are versioned via sys_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 overlay Page tree shows modified pages with a highlighting indicator The Workspaces module gives a full overview of all changes News with Content Elements (EXT:news) tx_news_domain_model_news has versioningWS = true by default. Workspace workflows work for news records. Watch out for: News categories ( sys_category ): versioned by default, works News tags ( tx_news_domain_model_tag ): check if versioningWS is enabled News detail page preview: configure preview page in TSconfig: # Page TSconfig for news preview in workspace options.workspaces.previewPageId.tx_news_domain_model_news = 42 Related news: MM relations are handled by DataHandler News images: FAL references are versioned, but physical files are not (see Section 2) Campaign/Seasonal Content with Scheduled Publish For temporary content (Christmas, Black Friday, product launches): Prepare all campaign content in the workspace Send changes to review and approval stage Publish (or schedule auto-publish) at campaign start Create a follow-up workspace change set to restore baseline content after campaign end Publish the rollback change set when the campaign is over Preview Links for External Reviewers # 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. Scheduler Auto-Publish Set a "Publish" date on the workspace record (Publishing tab) Create Scheduler task: "Workspaces auto-publication" Set task frequency (e.g., every 15 minutes) Only content in "Ready to publish" stage gets published # Cron job (runs every 15 minutes) */15 * * * * /path/to/bin/typo3 scheduler:run General Rules Use groups, not individual users for workspace ownership and membership Test workspace workflows before going live with workspace-based editing Document the review process for editors (which stages, who approves) Monitor disk space -- workspace versions accumulate in the database Clean up old workspace data periodically (discard unused versions) Keep patch level current -- workspace security issues exist (see TYPO3-CORE-SA-2025-022) 11. PSR-14 Events Reference AfterRecordPublishedEvent Fired after a record has been published from a workspace to live. 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 } } } SortVersionedDataEvent Fired after sorting data in the Workspaces backend module. Use to apply custom sorting. getData ( ) ; // ... modify $data ... $event -> setData ( $data ) ; } } All Available Events 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 ModifyVersionDifferencesEvent When computing diffs between live and workspace version SortVersionedDataEvent After sorting workspace version data in the module All events are in the \TYPO3\CMS\Workspaces\Event\ namespace. ?> 返回排行榜