typo3-workspaces
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseTYPO3 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.
兼容性: TYPO3 v13.x 和 v14.x(推荐使用v14) 本技能中的所有代码示例均适用于TYPO3 v13和v14版本。
TYPO3 API优先原则: 在创建自定义实现前,务必使用TYPO3内置的API、核心功能和既定规范。不要重复造轮子。使用前请务必通过官方TYPO3文档验证所使用的API和方法在目标TYPO3版本(v13或v14)中是否存在且未被弃用。
Sources
参考来源
This skill is based on 14 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
本技能基于14个权威来源:
- TYPO3 Workspaces 扩展文档
- 版本控制(Workspaces 扩展)
- 创建自定义工作区
- 配置选项(Workspaces)
- PSR-14 事件(Workspaces)
- 版本控制与工作区(TYPO3 核心API详解)
- TCA versioningWS 参考
- Restriction Builder(TYPO3 核心API详解)
- b13 博客:TYPO3 Overlay 的优雅效率(Benni Mack)
- 计划任务(Workspaces)
- 用户指南(Workspaces)
- TYPO3-CORE-SA-2025-022:Workspaces 模块信息泄露漏洞
- Forge Bug #88021:工作区中文件修改后FAL预览失败
- 本地化内容指南
1. Core Concepts
1. 核心概念
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.
工作区允许编辑器在不影响线上网站的前提下准备内容变更。变更会经过可配置的审核流程后再发布。
工作区分为两种类型:
- LIVE 工作区(ID=0):默认状态。所有变更会立即生效。必须明确授权给后端用户/用户组才能访问。
- 自定义工作区(ID>0):安全的编辑环境。变更会被版本化、可预览,并在上线前经过多阶段审核。
How Versioning Works (Database Level)
版本控制的数据库实现机制
Offline (workspace) versions live in the same database table as live records. They are identified by:
| Field | Purpose |
|---|---|
| Points to the live record's |
| Workspace ID this version belongs to (0 for live) |
| Special state flags (see below) |
| Workflow stage (0=editing, -10=ready to publish) |
| Set to |
t3ver_state values:
| Value | Meaning |
|---|---|
| New placeholder version (workspace pendant for new record) |
| Default: workspace modification of existing record |
| New placeholder (live pendant, insertion point for sorting) |
| Delete placeholder (record marked for deletion upon publish) |
| Move placeholder (live placeholder, hidden online, linked via |
| Move pointer (workspace pendant of record to be moved) |
离线(工作区)版本与线上记录存储在同一个数据库表中,通过以下字段标识:
| 字段 | 用途 |
|---|---|
| 指向线上记录的 |
| 该版本所属的工作区ID(线上记录为0) |
| 特殊状态标记(见下文) |
| 工作流阶段(0=编辑中,-10=待发布) |
| 所有离线版本的该值均设为 |
t3ver_state 取值说明:
| 取值 | 含义 |
|---|---|
| 新占位版本(新增记录的工作区对应版本) |
| 默认:现有记录的工作区修改版本 |
| 新占位符(线上对应版本,用于排序插入点) |
| 删除占位符(发布后该记录会被标记为删除) |
| 移动占位符(线上占位符,线上隐藏,通过 |
| 移动指针(待移动记录的工作区对应版本) |
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 AND
t3ver_oid=<uid>)t3ver_wsid=<current_ws> - Overlay language translation (search for in target language)
l10n_parent=<uid> - Overlay workspace version of translation
The of the live record is always preserved during overlay -- this keeps all references and links intact.
uidTYPO3 始终优先获取线上记录,然后在其之上覆盖工作区版本。对于带工作区的翻译内容,处理流程如下:
- 获取默认语言的线上记录(language=0,workspace=0)
- 覆盖工作区版本(查找且
t3ver_oid=<uid>的记录)t3ver_wsid=<当前工作区ID> - 覆盖语言翻译版本(查找目标语言中的记录)
l10n_parent=<uid> - 覆盖翻译版本的工作区版本
覆盖过程中始终保留线上记录的uid,确保所有引用和链接保持完整。
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 action is no longer allowed in TYPO3 v14. Use
swapinstead ofaction => 'publish'.action => 'swap'
- 发布:草稿内容通过工作区发布流程替换线上内容。
- TYPO3 v13/v14 使用发布工作流;请勿依赖旧版工作区级别的swap模式。
- 重要提示:TYPO3 v14 中不再允许使用操作,请使用
swap替代action => 'publish'。action => 'swap'参数已被swapWith(工作区版本uid)替代。uid
2. CRITICAL: File/FAL Limitation
2. 关键限制:文件/FAL
Files (FAL) are NOT versioned. This is the single most important limitation of TYPO3 Workspaces.
文件(FAL)不支持版本控制。这是TYPO3 Workspaces最重要的限制。
What This Means
具体影响
- Files in live exclusively in the LIVE workspace
fileadmin/ - If you overwrite a file, the change affects ALL workspaces immediately
- records are workspace-versioned; physical files and
sys_file_referencerecords are not versioned like content overlayssys_file - Replacing an image in a workspace content element may fail in preview (Forge #88021)
- 中的文件仅存在于LIVE工作区
fileadmin/ - 若你覆盖某个文件,该变更会立即影响所有工作区
- 记录支持工作区版本控制;但物理文件和
sys_file_reference记录不支持像内容那样的覆盖式版本控制sys_file - 在工作区内容元素中替换图片可能导致预览失败(参考Forge #88021)
The Predictable Filename Security Problem
可预测文件名带来的安全问题
Scenario: An editor creates a workspace version of a page replacing with . The workspace is NOT published yet. However:
geschaeftsbericht2024.pdfgeschaeftsbericht2025.pdf- The new PDF is uploaded to immediately (files are live!)
fileadmin/ - An external person guesses the URL by incrementing the year
- is accessible before the content element referencing it is published
geschaeftsbericht2025.pdf
This is a real security/confidentiality risk.
场景:编辑器在工作区中创建了一个页面版本,将替换为。此时工作区尚未发布,但:
geschaeftsbericht2024.pdfgeschaeftsbericht2025.pdf- 新PDF会立即上传到(文件属于线上环境!)
fileadmin/ - 外部人员通过年份递增猜测到URL
- 在引用它的内容元素发布前就已可访问
geschaeftsbericht2025.pdf
这是一个真实的安全/保密风险。
Workarounds
解决方案
1. Use non-guessable filenames:
undefined1. 使用不可猜测的文件名:
undefinedInstead of:
不推荐:
fileadmin/reports/geschaeftsbericht2025.pdf
fileadmin/reports/geschaeftsbericht2025.pdf
Use hashed/random names:
推荐使用哈希/随机命名:
fileadmin/reports/gb-a8f3e2b1c9d4.pdf
**2. Store confidential files outside the web root:**
```php
// config/system/settings.php
// Use a private storage that is NOT publicly accessible
// Deliver files programmatically via a controller3. 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
undefinedfileadmin/reports/gb-a8f3e2b1c9d4.pdf
**2. 将机密文件存储在Web根目录外:**
```php
// config/system/settings.php
// 使用不可公开访问的私有存储
// 通过控制器以编程方式交付文件3. 使用EXT:secure_downloads(leuchtfeuer/secure-downloads):
文件通过PHP脚本交付,会检查访问权限。不允许直接访问文件URL。
4. 对敏感目录进行服务器级保护:
apache
undefinedApache (.htaccess in a subdirectory)
Apache(在子目录的.htaccess中配置)
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
```
nginx
undefined<IfModule mod_authz_core.c>
Require all denied
</IfModule>
```
nginx
undefinedNGINX
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.location /fileadmin/confidential/ {
deny all;
return 403;
}
**5. 为每个工作区版本使用独立的文件引用:**
上传新文件时使用不同的名称,不要覆盖现有文件。工作区版本的内容元素引用新文件,线上版本保留旧文件。发布后,两个文件都会存在,但只有新文件会被引用。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 for files with predictable naming patterns
fileadmin/
- 切勿覆盖在多个工作区的内容元素中使用的文件
- 始终在准备工作区内容时使用唯一名称上传新文件
- 切勿依赖“页面尚未发布”来保护文件的保密性
- 考虑对所有机密文档使用EXT:secure_downloads
- 审计中使用可预测命名模式的文件
fileadmin/
3. Installation & Setup Checklist
3. 安装与设置检查清单
Installation
安装
bash
undefinedbash
undefinedComposer (v13/v14)
Composer(v13/v14)
composer require typo3/cms-workspaces
composer require typo3/cms-workspaces
Non-Composer: activate in Admin Tools > Extensions
非Composer方式:在管理工具>扩展中激活
undefinedundefinedComplete Setup Checklist
完整设置检查清单
Use this checklist to verify your workspace setup is complete:
使用以下清单验证工作区设置是否完整:
Extension & System
扩展与系统
- is installed and activated
typo3/cms-workspaces - Database schema is up to date ()
bin/typo3 database:updateschema - is installed (required for auto-publish)
typo3/cms-scheduler - Scheduler cron job is configured on server
- 已安装并激活
typo3/cms-workspaces - 数据库架构已更新()
bin/typo3 database:updateschema - 已安装(自动发布功能必需)
typo3/cms-scheduler - 服务器上已配置Scheduler定时任务
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 permissions for all relevant tables
tables_modify - Group has permissions on page types the workspace will contain
- 创建了工作区专用的后端用户组(例如:“工作区编辑器”)
- 该组拥有明确的LIVE工作区访问权限(挂载与工作区标签页>“Live”复选框)
- 该组拥有自定义工作区的访问权限(在工作区记录中被分配为所有者或成员)
- DB挂载设置正确(该组可编辑的页面)
- 文件挂载设置正确(该组可访问的目录)
- 该组拥有所有相关表的权限
tables_modify - 该组拥有工作区将包含的页面类型的权限
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
- 用户已被分配到工作区用户组
- 用户可在顶部栏看到工作区选择器
- 非管理员用户仅在需要时才拥有LIVE工作区访问权限
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)
- 工作区记录已作为系统记录创建在根页面(pid=0)
- 已设置标题和描述
- 所有者已分配(使用用户组,而非单个用户)
- 成员已分配(使用用户组,而非单个用户)
- 已根据需要创建自定义阶段(在“编辑中”和“待发布”之间)
- 已配置各阶段的通知设置
- 若工作区需限制到特定页面树,已设置挂载点
- 若需自动发布,已设置发布日期
- 已配置“发布权限”(必要时限制仅所有者可发布)
Tables (TCA)
表(TCA)
- All custom extension tables have in
'versioningWS' => true$GLOBALS['TCA'][<table>]['ctrl'] - Tables that must NOT be versioned have
'versioningWS' => false - Tables requiring live-editing in workspace have
'versioningWS_alwaysAllowLiveEdit' => true - database columns exist (auto-created when
t3ver_*)versioningWS = true
- 所有自定义扩展表的中已设置
$GLOBALS['TCA'][<table>]['ctrl']'versioningWS' => true - 不需要版本控制的表已设置
'versioningWS' => false - 需要在工作区中直接编辑线上内容的表已设置
'versioningWS_alwaysAllowLiveEdit' => true - 数据库列已存在(当
t3ver_*时会自动创建)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)
- 已创建并启用“工作区自动发布”任务
- 已创建并启用“工作区清理预览链接”任务
- 定时任务频率设置合理(例如:每15分钟一次)
TSconfig
TSconfig
- set (default: 48)
options.workspaces.previewLinkTTLHours - set if language restrictions needed
options.workspaces.allowed_languages.<wsId> - configured if preview modes should be limited
workspaces.splitPreviewModes - set for custom record preview pages
options.workspaces.previewPageId
- 已设置(默认:48)
options.workspaces.previewLinkTTLHours - 若需语言限制,已设置
options.workspaces.allowed_languages.<wsId> - 若需限制预览模式,已配置
workspaces.splitPreviewModes - 已为自定义记录预览页面设置
options.workspaces.previewPageId
4. DataHandler Setup Script
4. DataHandler 设置脚本
CLI Command: Create Workspace with DataHandler
CLI命令:使用DataHandler创建工作区
php
<?php
declare(strict_types=1);
namespace MyVendor\MySitepackage\Command;
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.
*
* Register in Configuration/Services.yaml:
* console.command.tag: 'console.command'
* command: 'workspace:setup'
*/
final class SetupWorkspaceCommand extends Command
{
protected function configure(): void
{
$this->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.yamlyaml
services:
MyVendor\MySitepackage\Command\SetupWorkspaceCommand:
tags:
- name: 'console.command'
command: 'workspace:setup'
description: 'Create workspace with base configuration'Run:
bash
undefinedphp
<?php
declare(strict_types=1);
namespace MyVendor\MySitepackage\Command;
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命令。
*
* 在Configuration/Services.yaml中注册:
* console.command.tag: 'console.command'
* command: 'workspace:setup'
*/
final class SetupWorkspaceCommand extends Command
{
protected function configure(): void
{
$this->setDescription('创建带后端用户组和基础配置的工作区');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
// 步骤1:为工作区编辑器创建后端用户组
$data = [];
$data['be_groups']['NEW_ws_group'] = [
'pid' => 0,
'title' => 'Workspace Editors',
'description' => '用于工作区编辑访问的后端用户组',
// 授予常用表的访问权限
'tables_modify' => 'pages,tt_content,sys_file_reference',
'tables_select' => 'pages,tt_content,sys_file_reference,sys_file',
// 授予LIVE工作区访问权限
'workspace_perms' => 1,
// 允许的页面类型
'pagetypes_select' => '1,3,4,6,7,199,254',
// 明确允许的内容类型
'explicit_allowdeny' => '',
];
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($data, []);
$dataHandler->process_datamap();
if (!empty($dataHandler->errorLog)) {
$io->error('创建后端用户组失败:' . implode(', ', $dataHandler->errorLog));
return Command::FAILURE;
}
$groupUid = $dataHandler->substNEWwithIDs['NEW_ws_group'] ?? 0;
$io->success('创建后端用户组"Workspace Editors"(uid=' . $groupUid . ')');
// 步骤2:创建自定义工作区
$data = [];
$data['sys_workspace']['NEW_workspace'] = [
'pid' => 0,
'title' => 'Staging Workspace',
'description' => '用于预览和审核的内容暂存工作区',
'adminusers' => 'be_groups_' . $groupUid,
'members' => 'be_groups_' . $groupUid,
// 阶段:默认阶段(编辑中、待发布)始终可用
// 发布权限:0 = 无限制,1 = 仅允许发布阶段的内容,2 = 仅所有者可发布
'publish_access' => 0,
// 允许编辑非版本化记录(线上编辑)
'edit_allow_notificaton_settings' => 0,
];
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($data, []);
$dataHandler->process_datamap();
if (!empty($dataHandler->errorLog)) {
$io->error('创建工作区失败:' . implode(', ', $dataHandler->errorLog));
return Command::FAILURE;
}
$wsUid = $dataHandler->substNEWwithIDs['NEW_workspace'] ?? 0;
$io->success('创建工作区"Staging Workspace"(uid=' . $wsUid . ')');
$io->section('后续步骤');
$io->listing([
'将后端用户分配到"Workspace Editors"组',
'在用户组上配置DB挂载和文件挂载',
'设置计划任务:"工作区自动发布" + "工作区清理预览链接"',
'配置TSconfig:options.workspaces.previewLinkTTLHours = 48',
'测试:在后端顶部栏切换到工作区并编辑页面',
]);
return Command::SUCCESS;
}
}在中注册:
Configuration/Services.yamlyaml
services:
MyVendor\MySitepackage\Command\SetupWorkspaceCommand:
tags:
- name: 'console.command'
command: 'workspace:setup'
description: '创建带基础配置的工作区'运行命令:
bash
undefinedDDEV
DDEV环境
ddev exec bin/typo3 workspace:setup
ddev exec bin/typo3 workspace:setup
Non-DDEV
非DDEV环境
bin/typo3 workspace:setup
undefinedbin/typo3 workspace:setup
undefinedSQL Setup (LOCAL DDEV ONLY)
SQL设置(仅本地DDEV环境)
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.
sql
-- ============================================================
-- 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;bash
undefined警告:这些SQL查询仅适用于本地DDEV开发环境。 切勿在生产环境运行原始SQL。 请使用上述DataHandler CLI命令替代。 原始SQL会绕过DataHandler、引用索引、历史记录和缓存清理。
sql
-- ============================================================
-- 仅本地DDEV环境使用 - 工作区基础配置
-- 将此代码块保存为setup-workspace.sql,然后运行:ddev mysql < setup-workspace.sql
-- ============================================================
-- 1. 为工作区编辑器创建后端用户组
INSERT INTO be_groups (pid, title, description, workspace_perms, tables_modify, tables_select, pagetypes_select, tstamp, crdate)
VALUES (
0,
'Workspace Editors',
'用于工作区编辑访问的后端用户组',
1, -- 1 = 拥有LIVE工作区访问权限
'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. 创建自定义工作区
INSERT INTO sys_workspace (pid, title, description, adminusers, members, publish_access, tstamp, crdate)
VALUES (
0,
'Staging Workspace',
'用于预览和审核的内容暂存工作区',
CONCAT('be_groups_', @group_uid),
CONCAT('be_groups_', @group_uid),
0, -- 0 = 无发布限制
UNIX_TIMESTAMP(),
UNIX_TIMESTAMP()
);
SET @ws_uid = LAST_INSERT_ID();
-- 3. 验证
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. 检查已启用工作区的表
SELECT TABLE_NAME
FROM information_schema.COLUMNS
WHERE COLUMN_NAME = 't3ver_oid'
AND TABLE_SCHEMA = DATABASE()
ORDER BY TABLE_NAME;bash
undefinedRun in DDEV:
在DDEV环境中运行:
ddev mysql < setup-workspace.sql
undefinedddev mysql < setup-workspace.sql
undefined5. Making Extensions Workspace-Compatible
5. 使扩展支持工作区
Step 1: Enable versioningWS in TCA
步骤1:在TCA中启用versioningWS
php
<?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 database columns are auto-created when -- you do NOT need to add them to .
t3ver_*versioningWS = trueext_tables.sqlAfter enabling, run:
bash
bin/typo3 database:updateschemaphp
<?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',
// 启用工作区版本控制
'versioningWS' => true,
// 语言支持
'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',
],
// ... 列、类型、面板配置
];当时,数据库列会自动创建——你无需将它们添加到中。
versioningWS = truet3ver_*ext_tables.sql启用后运行:
bash
bin/typo3 database:updateschemaStep 2: Disable Versioning for Specific Tables
步骤2:为特定表禁用版本控制
If a table must NOT be versioned (e.g., logging, statistics):
php
<?php
// EXT:my_extension/Configuration/TCA/Overrides/tx_myextension_log.php
$GLOBALS['TCA']['tx_myextension_log']['ctrl']['versioningWS'] = false;若某个表不需要版本控制(例如:日志、统计数据):
php
<?php
// EXT:my_extension/Configuration/TCA/Overrides/tx_myextension_log.php
$GLOBALS['TCA']['tx_myextension_log']['ctrl']['versioningWS'] = false;Step 3: Allow Live Editing in Workspaces
步骤3:允许在工作区中直接编辑线上内容
For tables that should always be edited live (e.g., user settings, configuration):
php
<?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
<?php
// EXT:my_extension/Configuration/TCA/Overrides/tx_myextension_settings.php
// 即使在工作区激活时,该表的记录也会在线编辑
$GLOBALS['TCA']['tx_myextension_settings']['ctrl']['versioningWS_alwaysAllowLiveEdit'] = true;Step 4: Backend Overlay (REQUIRED for Backend Modules)
步骤4:后端覆盖(后端模块必需)
php
<?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
<?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,
) {}
/**
* 获取带工作区覆盖的单条记录。
*/
public function getItem(int $uid): ?array
{
// 选项A:一行代码(推荐)
$row = BackendUtility::getRecordWSOL('tx_myextension_domain_model_item', $uid);
// 选项B:手动实现(与A等效)
// $row = BackendUtility::getRecord('tx_myextension_domain_model_item', $uid);
// BackendUtility::workspaceOL('tx_myextension_domain_model_item', $row);
return is_array($row) ? $row : null;
}
/**
* 获取带工作区覆盖的多条记录。
*/
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()) {
// 关键:为每条记录应用工作区覆盖
BackendUtility::workspaceOL('tx_myextension_domain_model_item', $row);
if (is_array($row)) {
$items[] = $row;
}
}
return $items;
}
}Step 5: Frontend Overlay (REQUIRED for Frontend Plugins)
步骤5:前端覆盖(前端插件必需)
php
<?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
<?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,
) {}
/**
* 获取带工作区+语言覆盖的记录,用于前端渲染。
*/
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()) {
// 应用工作区覆盖(必须在语言覆盖之前调用)
$this->pageRepository->versionOL('tx_myextension_domain_model_item', $row);
if (!is_array($row)) {
continue;
}
// 应用语言覆盖
$row = $this->pageRepository->getLanguageOverlay(
'tx_myextension_domain_model_item',
$row
);
if (is_array($row)) {
$items[] = $row;
}
}
return $items;
}
}Step 6: Backend Module Workspace Restriction
步骤6:后端模块工作区限制
php
<?php
// EXT:my_extension/Configuration/Backend/Modules.php
return [
'my_module' => [
'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',
],
],
],
];php
<?php
// EXT:my_extension/Configuration/Backend/Modules.php
return [
'my_module' => [
'parent' => 'web',
'position' => ['after' => 'web_info'],
'access' => 'user',
// 控制工作区可用性:
// '*' = 在所有工作区中可用(默认)
// 'live' = 仅在LIVE工作区中可用
// 'offline' = 仅在自定义工作区中可用
'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
6. 查询迁移:从无工作区到支持工作区
Pattern A: Backend QueryBuilder (Before/After)
模式A:后端QueryBuilder(前后对比)
BEFORE (no workspace awareness):
php
<?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
<?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;
}
}之前(无工作区支持):
php
<?php
// 错误:不支持工作区
$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();
// 查询结果可能包含工作区占位符,且缺少工作区版本!之后(支持工作区):
php
<?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()) {
// 应用工作区覆盖
BackendUtility::workspaceOL('tt_content', $row);
if (is_array($row)) {
$rows[] = $row;
}
}Pattern B: Frontend with WorkspaceRestriction
模式B:前端使用WorkspaceRestriction
BEFORE (no workspace restriction):
php
<?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
<?php
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
// FrontendRestrictionContainer includes:
// - DeletedRestriction
// - HiddenRestriction
// - StartTimeRestriction
// - EndTimeRestriction
// - WorkspaceRestriction <-- automatically included!
// - FrontendGroupRestriction
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_news_domain_model_news');
$queryBuilder->setRestrictions(
GeneralUtility::makeInstance(FrontendRestrictionContainer::class)
);之前(无工作区限制):
php
<?php
// 前端场景下错误:缺少WorkspaceRestriction
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_news_domain_model_news');
$queryBuilder->getRestrictions()->removeAll()->add(
GeneralUtility::makeInstance(DeletedRestriction::class)
);之后(正确的前端限制):
php
<?php
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
// FrontendRestrictionContainer包含:
// - DeletedRestriction
// - HiddenRestriction
// - StartTimeRestriction
// - EndTimeRestriction
// - WorkspaceRestriction <-- 自动包含!
// - FrontendGroupRestriction
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_news_domain_model_news');
$queryBuilder->setRestrictions(
GeneralUtility::makeInstance(FrontendRestrictionContainer::class)
);Pattern C: Adding WorkspaceRestriction Manually
模式C:手动添加WorkspaceRestriction
php
<?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:only filters the SQL result to exclude wrong workspace records. You still must callWorkspaceRestrictionorversionOL()on each row to get the actual workspace content overlaid.workspaceOL()
php
<?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));重要提示:仅用于过滤SQL结果,排除不属于当前工作区的记录。你仍需对每条记录调用WorkspaceRestriction或versionOL()以获取实际的工作区内容覆盖。workspaceOL()
Pattern D: Extbase Repository (Mostly Transparent)
模式D:Extbase仓库(基本无需修改)
Extbase repositories handle workspace overlays automatically via the persistence layer. No changes needed for standard methods.
findBy*However, if you use custom QueryBuilder inside a repository:
php
<?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 constructor is called automatically via DI
}
/**
* 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;
}
}Extbase仓库通过持久化层自动处理工作区覆盖。标准的方法无需修改。
findBy*但,若你在仓库中使用自定义QueryBuilder:
php
<?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,
) {
// 父类构造函数会通过DI自动调用
}
/**
* 需要手动处理工作区的自定义查询。
*/
public function findItemsWithCustomQuery(int $categoryId): array
{
$queryBuilder = $this->connectionPool
->getQueryBuilderForTable('tx_myextension_domain_model_item');
// 使用FrontendRestrictionContainer进行正确的工作区过滤
$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)
模式E:旧版查询迁移(从TYPO3 v10/v11风格到v13/v14)
BEFORE (deprecated -- old exec_SELECTquery pattern, no longer available):
php
<?php
// DEPRECATED: Do NOT use. Not available in v13/v14.
// $res = $GLOBALS['TYPO3_DB']->exec_SELECTquery('*', 'tt_content', 'pid=' . (int)$pageId);AFTER (modern QueryBuilder with workspace support):
php
<?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
}
}之前(已弃用——旧的exec_SELECTquery模式,v13/v14中已移除):
php
<?php
// 已弃用:请勿使用。v13/v14中已不存在。
// $res = $GLOBALS['TYPO3_DB']->exec_SELECTquery('*', 'tt_content', 'pid=' . (int)$pageId);之后(支持工作区的现代QueryBuilder):
php
<?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)) {
// 处理带工作区覆盖的记录
}
}7. Translations with Workspaces
7. 带工作区的翻译
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 translationTYPO3 handles this automatically when using and .
PageRepository->versionOL()PageRepository->getLanguageOverlay()当在工作区中渲染非默认语言的页面时:
线上记录(lang=0,ws=0)
└─► 工作区覆盖(lang=0,ws=1) ← 默认语言的工作区版本
└─► 语言覆盖(lang=1,ws=0) ← 线上记录的翻译版本
└─► 工作区覆盖(lang=1,ws=1) ← 翻译版本的工作区版本当使用和时,TYPO3会自动处理上述流程。
PageRepository->versionOL()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 field points to the live default language record UID
l10n_parent - Each translation version has its own pointing to the live translation
t3ver_oid
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) |
在工作区中工作时:
- 默认语言记录会先被版本化(若被修改)
- 在工作区中创建的翻译会有自己的占位符+版本记录
- 字段指向线上默认语言记录的UID
l10n_parent - 每个翻译版本都有自己的指向线上翻译记录
t3ver_oid
数据库示例:在工作区1中被翻译成法语的tt_content记录
| 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:
undefined限制用户在特定工作区中可编辑的语言:
undefinedUser TSconfig
用户TSconfig
Allow only French (uid=1) and German (uid=2) in workspace 3
允许在工作区3中仅编辑法语(uid=1)和德语(uid=2)
options.workspaces.allowed_languages.3 = 1,2
undefinedoptions.workspaces.allowed_languages.3 = 1,2
undefinedFile Translations with Workspaces
带工作区的文件翻译
The same file limitation applies to translations:
- Translated file references (with
sys_file_reference) ARE versionedsys_language_uid > 0 - 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
文件限制同样适用于翻译内容:
- 已翻译的文件引用(且
sys_file_reference)支持版本控制sys_language_uid > 0 - 物理文件不支持版本控制
- 若翻译内容需要不同的PDF/图片,请上传新文件并使用唯一名称
- 不要覆盖在多语言版本间共享的文件
8. Debugging & Troubleshooting
8. 调试与故障排除
Quick Diagnostic Checklist
快速诊断清单
When workspaces "don't work", check these in order:
| # | Check | How |
|---|---|---|
| 1 | Extension installed? | |
| 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 |
| 6 | DB columns exist? | |
| 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? | |
| 10 | Cache cleared? | |
当工作区“无法正常工作”时,按以下顺序检查:
| # | 检查项 | 检查方法 |
|---|---|---|
| 1 | 扩展已安装? | |
| 2 | 工作区记录存在? | 在根页面的列表模块中筛选系统记录 |
| 3 | 用户拥有工作区访问权限? | 后端用户/用户组 > 挂载与工作区标签页 |
| 4 | 用户拥有LIVE访问权限? | 同一标签页,“Live”复选框必须勾选 |
| 5 | 表已启用versioningWS? | 检查 |
| 6 | 数据库列存在? | |
| 7 | DB挂载正确? | 用户/用户组必须拥有覆盖待编辑页面的DB挂载 |
| 8 | 文件挂载正确? | 用户/用户组必须拥有媒体访问的文件挂载 |
| 9 | 架构已更新? | |
| 10 | 缓存已清除? | |
SQL Queries for Debugging (DDEV Local Only)
调试用SQL查询(仅本地DDEV环境)
sql
-- 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;sql
-- 检查工作区记录是否存在
SELECT uid, title, adminusers, members, publish_access
FROM sys_workspace
WHERE deleted = 0;
-- 检查特定页面的版本化记录
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;
-- 查找孤立的工作区记录(指向已删除的线上记录)
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);
-- 检查已启用工作区的表
SELECT TABLE_NAME
FROM information_schema.COLUMNS
WHERE COLUMN_NAME = 't3ver_oid'
AND TABLE_SCHEMA = DATABASE()
ORDER BY TABLE_NAME;
-- 检查后端用户的工作区权限
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;
-- 检查sys_log中的工作区操作
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
程序化权限检查
php
<?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);
// 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)php
<?php
declare(strict_types=1);
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Utility\GeneralUtility;
// 获取当前工作区ID
$workspaceId = GeneralUtility::makeInstance(Context::class)
->getPropertyFromAspect('workspace', 'id', 0);
// 检查用户是否可在当前工作区中编辑
$cannotEdit = $GLOBALS['BE_USER']->workspaceCannotEditRecord('tt_content', $row);
if ($cannotEdit) {
// $cannotEdit包含解释原因的错误信息
throw new \RuntimeException('无法编辑:' . $cannotEdit);
}
// 检查是否可在页面上创建新记录
$canCreate = $GLOBALS['BE_USER']->workspaceCreateNewRecord($pageId, 'tt_content');
// 检查用户的工作区访问级别
$workspaceAccess = $GLOBALS['BE_USER']->checkWorkspace($workspaceId);
// 返回值:包含'uid'和'_ACCESS'键的数组('admin'、'owner'、'member'或false)Common Issues & Solutions
常见问题与解决方案
| Issue | Cause | Solution |
|---|---|---|
| Records not visible in workspace | | Set |
| Workspace changes visible on live site | Missing | Add |
| "Editing not possible" error | User lacks edit permission or stage access | Check user/group |
| Preview shows wrong content | Missing | Add |
| 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 |
| 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
9. 测试工作区支持
Prerequisites: Install Testing Framework
前提条件:安装测试框架
Step 1: Require dev dependencies
bash
undefined步骤1:安装开发依赖
bash
undefinedDDEV
DDEV环境
ddev composer require --dev typo3/testing-framework:"^9.0" phpunit/phpunit:"^11.0"
ddev composer require --dev typo3/testing-framework:"^9.0" phpunit/phpunit:"^11.0"
Non-DDEV
非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:
```xml
<?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
bash
mkdir -p Tests/Functional/Fixturesyour-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.phpStep 4: Ensure composer.json has autoload-dev
json
{
"autoload-dev": {
"psr-4": {
"MyVendor\\MyExtension\\Tests\\": "Tests/"
}
}
}Then run:
bash
undefinedcomposer require --dev typo3/testing-framework:"^9.0" phpunit/phpunit:"^11.0"
> 根据你的TYPO3版本调整`typo3/testing-framework`版本:
> - TYPO3 v13:`typo3/testing-framework:"^8.2 || ^9.0"`
> - TYPO3 v14:`typo3/testing-framework:"^9.0"`
**步骤2:为功能测试创建PHPUnit配置**
在你的扩展根目录创建`Build/phpunit-functional.xml`:
```xml
<?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>
<!-- 功能测试需要数据库。DDEV会自动提供。 -->
<!-- 非DDEV环境:设置typo3DatabaseHost、typo3DatabaseUsername等。 -->
<!-- DDEV示例(自动检测,通常无需环境变量): -->
<!-- <env name="typo3DatabaseHost" value="db"/> -->
<!-- <env name="typo3DatabaseUsername" value="db"/> -->
<!-- <env name="typo3DatabasePassword" value="db"/> -->
<!-- <env name="typo3DatabaseName" value="db"/> -->
</php>
</phpunit>步骤3:创建目录结构
bash
mkdir -p Tests/Functional/Fixturesyour-extension/
├── Build/
│ └── phpunit-functional.xml ← PHPUnit配置
├── Classes/
│ └── ...
├── Configuration/
│ └── TCA/
├── Tests/
│ └── Functional/
│ ├── Fixtures/
│ │ └── WorkspaceTestData.csv ← 测试数据
│ └── WorkspaceAwareTest.php ← 测试类
├── composer.json
└── ext_emconf.php步骤4:确保composer.json包含autoload-dev
json
{
"autoload-dev": {
"psr-4": {
"MyVendor\\MyExtension\\Tests\\": "Tests/"
}
}
}然后运行:
bash
undefinedDDEV
DDEV环境
ddev composer dump-autoload
ddev composer dump-autoload
Non-DDEV
非DDEV环境
composer dump-autoload
undefinedcomposer dump-autoload
undefinedFunctional Test Setup
功能测试设置
php
<?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.
*
* 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);
}
}php
<?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
{
/**
* 为该类中的所有测试加载workspaces扩展。
*/
protected array $coreExtensionsToLoad = [
'workspaces',
];
/**
* 加载你自己的扩展。
*/
protected array $testExtensionsToLoad = [
'typo3conf/ext/my_extension',
];
protected function setUp(): void
{
parent::setUp();
// 导入基础数据:页面、内容、工作区记录、后端用户
$this->importCSVDataSet(__DIR__ . '/Fixtures/WorkspaceTestData.csv');
// 初始化后端用户(fixture中的uid=1,必须是管理员才能使用DataHandler)
$this->setUpBackendUser(1);
Bootstrap::initializeLanguageObject();
}
/**
* 辅助方法:设置当前工作区上下文。
*/
private function setWorkspaceId(int $workspaceId): void
{
$context = GeneralUtility::makeInstance(Context::class);
$context->setAspect('workspace', new WorkspaceAspect($workspaceId));
$GLOBALS['BE_USER']->setWorkspace($workspaceId);
}
/**
* 测试:在工作区中创建的记录不会出现在线上环境。
*/
public function testRecordCreatedInWorkspaceNotVisibleInLive(): void
{
// 切换到工作区1
$this->setWorkspaceId(1);
// 通过DataHandler在工作区中创建记录
$data = [
'tt_content' => [
'NEW_1' => [
'pid' => 1,
'CType' => 'text',
'header' => '仅工作区可见内容',
'bodytext' => '<p>该内容不应出现在线上</p>',
],
],
];
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start($data, []);
$dataHandler->process_datamap();
self::assertEmpty($dataHandler->errorLog, 'DataHandler错误:' . implode(', ', $dataHandler->errorLog));
// 切换回LIVE工作区
$this->setWorkspaceId(0);
// 查询线上记录——工作区记录不应出现
$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) // 排除新占位符
)
->executeQuery()
->fetchAllAssociative();
$headers = array_column($liveRecords, 'header');
self::assertNotContains('仅工作区可见内容', $headers);
}
/**
* 测试:工作区覆盖返回修改后的内容。
*/
public function testWorkspaceOverlayReturnsModifiedContent(): void
{
// Fixture中存在uid=10的记录,标题为“原始标题”
// 工作区版本的标题为“工作区中修改的标题”
$this->setWorkspaceId(1);
$row = \TYPO3\CMS\Backend\Utility\BackendUtility::getRecordWSOL('tt_content', 10);
self::assertIsArray($row);
self::assertSame('工作区中修改的标题', $row['header']);
}
/**
* 测试:工作区发布命令将暂存内容推送到线上(记录对)。
*
* 已弃用:TYPO3 v14中不再允许使用'swap'操作。
* 请使用'action' => 'publish'替代。'swapWith'参数已被'uid'(工作区版本uid)替代。
*/
public function testPublishWorkspaceRecordPairMakesContentLive(): void
{
$this->setWorkspaceId(1);
// 发布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);
// 切换到LIVE并验证
$this->setWorkspaceId(0);
$row = \TYPO3\CMS\Backend\Utility\BackendUtility::getRecord('tt_content', 10);
self::assertSame('工作区中修改的标题', $row['header']);
}
/**
* 辅助方法:获取记录的工作区版本uid。
*/
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
CSV测试数据文件
Create :
Tests/Functional/Fixtures/WorkspaceTestData.csvcsv
"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创建:
Tests/Functional/Fixtures/WorkspaceTestData.csvcsv
"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,"测试工作区","1","1",0
"pages"
,"uid","pid","title","slug","deleted","t3ver_oid","t3ver_wsid","t3ver_state"
,1,0,"测试页面","/test-page",0,0,0,0
"tt_content"
,"uid","pid","header","CType","bodytext","deleted","t3ver_oid","t3ver_wsid","t3ver_state"
,10,1,"原始标题","text","<p>原始内容</p>",0,0,0,0
,11,-1,"工作区中修改的标题","text","<p>修改后的内容</p>",0,10,1,0Run Tests
运行测试
DDEV (recommended):
bash
undefinedDDEV环境(推荐):
bash
undefinedRun all workspace functional tests
运行所有工作区功能测试
ddev exec bin/phpunit -c Build/phpunit-functional.xml
Tests/Functional/WorkspaceAwareTest.php
Tests/Functional/WorkspaceAwareTest.php
ddev exec bin/phpunit -c Build/phpunit-functional.xml
Tests/Functional/WorkspaceAwareTest.php
Tests/Functional/WorkspaceAwareTest.php
Run a single test method
运行单个测试方法
ddev exec bin/phpunit -c Build/phpunit-functional.xml
--filter testWorkspaceOverlayReturnsModifiedContent
Tests/Functional/WorkspaceAwareTest.php
--filter testWorkspaceOverlayReturnsModifiedContent
Tests/Functional/WorkspaceAwareTest.php
ddev exec bin/phpunit -c Build/phpunit-functional.xml
--filter testWorkspaceOverlayReturnsModifiedContent
Tests/Functional/WorkspaceAwareTest.php
--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
-v Tests/Functional/WorkspaceAwareTest.php
DDEV auto-provides the test database. No extra env vars needed.
**Non-DDEV (manual database config):**
```bashddev exec bin/phpunit -c Build/phpunit-functional.xml
-v Tests/Functional/WorkspaceAwareTest.php
-v Tests/Functional/WorkspaceAwareTest.php
DDEV会自动提供测试数据库,无需额外环境变量。
**非DDEV环境(手动数据库配置):**
```bashSet 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
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 |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
Tests/Functional/WorkspaceAwareTest.php
> 测试框架会为每个测试用例创建**临时数据库**。你的环境变量指向数据库服务器——框架会处理其余操作。
**测试失败故障排除:**
| 错误 | 原因 | 解决方法 |
|-------|-------|-----|
| `Table 'sys_workspace' doesn't exist` | `workspaces`未加入`$coreExtensionsToLoad` | 将`'workspaces'`添加到数组中 |
| `Table 'be_users' has no column 'workspace_perms'` | 测试数据库未创建对应的表结构 | 确保在`setUp()`之前加载`workspaces`扩展 |
| `Access denied for user` | 数据库凭据错误 | 检查环境变量或DDEV状态(`ddev describe`) |
| `Call to undefined method setUpBackendUser` | testing-framework版本错误 | TYPO3 v13使用`typo3/testing-framework ^8.2`,v14使用`^9.0` |
| `Record not found after DataHandler` | CSV测试数据格式错误 | 验证CSV:第一行是表名,第二行是列标题,数据行以逗号开头 |10. Best Practices
10. 最佳实践
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:
- and
pageshavett_contentby defaultversioningWS = true - FAL relations (images, media) are versioned via 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
sys_file_reference - Page tree shows modified pages with a highlighting indicator
- The Workspaces module gives a full overview of all changes
- 编辑器通过顶部栏选择器切换到自定义工作区
- 编辑器导航到页面并编辑内容元素
- TYPO3自动为修改后的记录创建工作区版本
- 编辑器通过“查看网页”按钮预览(显示工作区版本)
- 编辑器将变更提交到“待发布”阶段
- 审核者批准或附带评论退回
- 发布者发布到线上(或通过计划任务自动发布)
关键点:
- 和
pages默认已设置tt_contentversioningWS = true - FAL关联(图片、媒体)通过覆盖实现版本控制(物理文件不支持版本控制);MM关联(分类)通过父记录覆盖/DataHandler关联处理实现;简单字段(链接、文本)直接在记录覆盖中实现版本控制
sys_file_reference - 页面树会高亮显示已修改的页面
- 工作区模块可查看所有变更的完整概览
News with Content Elements (EXT:news)
带内容元素的新闻(EXT:news)
tx_news_domain_model_newsversioningWS = trueWatch out for:
- News categories (): versioned by default, works
sys_category - News tags (): check if
tx_news_domain_model_tagis enabledversioningWS - News detail page preview: configure preview page in TSconfig:
undefinedtx_news_domain_model_newsversioningWS = true注意事项:
- 新闻分类():默认支持版本控制,可正常工作
sys_category - 新闻标签():检查是否已启用
tx_news_domain_model_tagversioningWS - 新闻详情页预览:在TSconfig中配置预览页面:
undefinedPage TSconfig for news preview in workspace
用于工作区中新闻预览的页面TSconfig
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)options.workspaces.previewPageId.tx_news_domain_model_news = 42
- 相关新闻:MM关联由DataHandler处理
- 新闻图片:FAL引用支持版本控制,但**物理文件不支持**(见第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
供外部审核者使用的预览链接
undefinedundefinedUser TSconfig -- set preview link expiry
用户TSconfig -- 设置预览链接有效期
options.workspaces.previewLinkTTLHours = 72
Generate via Workspaces module: "Generate page preview links" button. The link works without any TYPO3 backend access.options.workspaces.previewLinkTTLHours = 72
通过工作区模块生成:点击“生成页面预览链接”按钮。该链接无需TYPO3后端权限即可访问。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
bash
undefined- 在工作区记录的“发布”标签页设置“发布”日期
- 创建计划任务:“工作区自动发布”
- 设置任务频率(例如:每15分钟一次)
- 只有处于“待发布”阶段的内容会被发布
bash
undefinedCron job (runs every 15 minutes)
定时任务(每15分钟运行一次)
*/15 * * * * /path/to/bin/typo3 scheduler:run
undefined*/15 * * * * /path/to/bin/typo3 scheduler:run
undefinedGeneral 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)
- 使用用户组而非单个用户作为工作区所有者和成员
- 在基于工作区的编辑上线前测试工作流
- 为编辑器记录审核流程(包含哪些阶段、谁负责批准)
- 监控磁盘空间——工作区版本会在数据库中累积
- 定期清理旧的工作区数据(丢弃未使用的版本)
- 保持补丁更新——工作区存在安全问题(参考TYPO3-CORE-SA-2025-022)
11. PSR-14 Events Reference
11. PSR-14 事件参考
AfterRecordPublishedEvent
AfterRecordPublishedEvent
Fired after a record has been published from a workspace to live.
php
<?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
}
}
}记录从工作区发布到线上后触发。
php
<?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();
// 示例:发布后清除外部CDN缓存
if ($table === 'pages') {
// 触发CDN清除对应页面的缓存
}
// 示例:通知外部系统
if ($table === 'tx_news_domain_model_news') {
// 向新闻通讯系统发送Webhook
}
}
}SortVersionedDataEvent
SortVersionedDataEvent
Fired after sorting data in the Workspaces backend module. Use to apply custom sorting.
php
<?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);
}
}在工作区后端模块中对数据排序后触发。用于应用自定义排序逻辑。
php
<?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
{
// 工作区模块的自定义排序逻辑
$data = $event->getData();
// ... 修改$data ...
$event->setData($data);
}
}All Available Events
所有可用事件
| Event | When Fired |
|---|---|
| After compiling cacheable workspace version data |
| After generating all workspace version data |
| After a record is published to live |
| After preparing/cleaning workspace version data |
| When computing diffs between live and workspace version |
| After sorting workspace version data in the module |
All events are in the namespace.
\TYPO3\CMS\Workspaces\Event\| 事件 | 触发时机 |
|---|---|
| 编译工作区版本的可缓存数据后 |
| 生成所有工作区版本数据后 |
| 记录发布到线上后 |
| 准备/清理工作区版本数据后 |
| 计算线上版本与工作区版本的差异时 |
| 在模块中对工作区版本数据排序后 |
所有事件均位于命名空间下。
\TYPO3\CMS\Workspaces\Event\Related Skills
相关技能
- typo3-datahandler -- DataHandler operations (used for all record manipulation in workspaces)
- typo3-testing -- Testing infrastructure (functional tests for workspace support)
- typo3-security -- Security hardening (file access, permissions)
- typo3-seo -- SEO configuration (preview links, robots handling)
- typo3-datahandler -- DataHandler操作(工作区中所有记录操作均使用该工具)
- typo3-testing -- 测试基础设施(工作区支持的功能测试)
- typo3-security -- 安全加固(文件访问、权限)
- typo3-seo -- SEO配置(预览链接、robots处理)
Credits & Attribution
致谢与归属
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.
Thanks to Netresearch DTT GmbH for their contributions to the TYPO3 community.
本技能基于官方TYPO3 CMS文档和社区资源。特别感谢TYPO3核心团队和**b13 GmbH**(Benni Mack)对覆盖机制的精彩讲解。
感谢Netresearch DTT GmbH对TYPO3社区的贡献。