Token导航 LogoToken导航

Agent Skill · 研究检索

typo3-workspaces(错别字 3 工作区)

typo3-workspaces 用于查找、检索和筛选相关信息,适合在 Codex、Claude、Cursor、Gemini CLI 中需要根据关键词、任务场景或来源线索快速定位候选结果时使用。可结合来源仓库、安装命令和原始 README 继续核验具体用法。安装前建议确认权限范围、维护状态,以及是否会触发联网、命令执行或文件读写。

typo3-workspaces 是研究检索类 Agent Skill,可用宿主为 Codex、Claude、Cursor、Gemini CLI,通过GitHub 仓库获取,权限为需要联网;许可证信息还不明确,第三方扫描已通过,按安装量在研究检索类的 73,675 个 Skill 里排第 43,786。

总安装 960周安装 40GitHub Stars 26下载 320核验 2026-05-01

安装说明

本站只整理中文说明,不托管安装包,也不代用户安装。
GitHub
许可证
unknown

安装方式

通过对话安装

复制提示词发给支持本地命令或 Skills 的 AI 助手,先确认命令和权限,再让它执行。

请帮我安装这个 Agent Skill:typo3-workspaces(错别字 3 工作区)
来源仓库:https://github.com/dirnbauer/webconsulting-skills
仓库路径:skills/typo3-workspaces
安装命令:
npx skills add https://github.com/dirnbauer/webconsulting-skills --skill typo3-workspaces
安装前请先检查当前环境是否支持对应 CLI,并向我确认将要执行的命令、安装目录、联网范围和文件读写权限;确认后再执行。

命令行安装

复制命令到本机终端执行。该命令会通过 npx skills 从第三方来源获取 Skill;本站只展示命令,不托管安装包,也不自动执行。

skills.shnpx skills
npx skills add https://github.com/dirnbauer/webconsulting-skills --skill typo3-workspaces

这个 Skill 是做什么的

研究检索类 Skill 主要解决「找资料、核对事实、把来源整理清楚」的问题,通常会让助手先检索再总结。

typo3-workspaces 用于查找、检索和筛选相关信息。适合在 Codex、Claude、Cursor、Gemini CLI 中根据关键词、任务场景或来源线索快速定位候选结果。通过 npx skills add 命令从指定仓库安装,需结合原始 README 核验具体用法。

安装前建议确认权限范围、维护状态,以及是否会触发联网、命令执行或文件读写。当前无原始 SKILL.md 内容可参考,功能描述基于通用检索场景推断。

来源仓库内路径:skills/typo3-workspaces

上手步骤

4 步
  1. 1

    确认自己的宿主工具

    公开资料标注支持 Codex、Claude、Cursor、Gemini CLI;先确认自己用的是其中之一,再按对应工具的 Skills 目录放置。

  2. 2

    拿到安装命令

    可以直接复制的命令是 npx skills add https://github.com/dirnbauer/webconsulting-skills --skill typo3-w…;命令里的占位符换成自己的路径或密钥再执行。

  3. 3

    安装后重启宿主

    命令执行完要完全退出宿主工具再打开,让新装的 Skill 被重新扫描到,否则列表里可能看不到。

  4. 4

    按最小权限试用

    它需要需要联网,先在测试目录或测试账号里跑,确认没问题再放到正式项目。

常见问题

6 条

typo3-workspaces 怎么安装?

在终端执行 npx skills add https://github.com/dirnbauer/webconsulting-skills --skill typo3-workspaces。装完完全退出宿主工具再打开,让它重新扫描本地 Skill。

typo3-workspaces 支持哪些 AI 工具?

公开资料标注支持 Codex、Claude、Cursor、Gemini CLI。其他工具只要能读取本地 Skill 目录,一般也能手动放进去,但要自己确认触发方式是否一致。

typo3-workspaces 需要什么权限?

权限级别是需要联网。该 Skill 可能需要联网访问来源站点、仓库或外部 API;具体网络访问范围需要结合源码和 README 复核。

typo3-workspaces 是免费的吗?

公开资料没有标注明确许可证,收费和商用限制都无法确认。正式使用前请到来源仓库核对授权说明。

typo3-workspaces 用的人多吗?

公开数据是累计安装 960 次,近一周安装 40 次,下载 320 次,GitHub 26 星。这些数字来自第三方目录统计,只能当作热度参考,不代表质量或安全性。

typo3-workspaces 安全吗?

第三方扫描结果为通过,但这只覆盖已知的静态检查项,不能替代自己读一遍源码和安装命令。

值得选的地方和要先想清楚的地方

值得选的地方

  • 同时适配 4 种工具,换客户端不用重新找替代品。
  • 第三方扫描已通过,常见的高风险行为没有命中。

先想清楚的地方

  • 许可证没有明确标注,能不能商用、能不能再分发都不确定。
  • 第三方目录整理,版本更新和行为细节以来源仓库为准。

适不适合你

更适合

  • 已经在用 Codex,想直接扩展它能力的人。
  • 需要处理研究检索类任务、又不想每次都手写提示词的人。

不太适合

  • 需要明确授权才能用到公司项目里的场景。
  • 需要厂商级技术支持和 SLA 的生产系统。

研究检索类 Skill 对比

6 个同类
Skill总安装GitHub 星权限许可证扫描
find-skills

寻找技能 · Codex、Claude

31,199,81316,486操作浏览器MIT有提醒
self-improving-agent

自我改进 Agent · OpenClaw

16,464,0023,396接触敏感数据MIT-0通过
soultrace

灵魂追踪 · Codex、Claude

7,588,94412需要联网未明确有提醒
self-improving

自我完善 · OpenClaw

6,829,2841,046需要联网MIT-0通过
microsoft-foundry

微软代工厂 · Codex、Claude

6,614,144764调用外部服务未明确有提醒
typo3-workspaces

错别字 3 工作区 · 本页

96026需要联网未明确通过

按安装量取同分类前几名;数字来自第三方目录统计,只作热度参考,不代表质量结论。

SKILL.md 原文

TYPO3 Workspaces

Compatibility: TYPO3 v14.x All code examples in this skill are designed to work on TYPO3 v14.
TYPO3 API First: Always use TYPO3's built-in APIs, core features, and established conventions before creating custom implementations. Do not reinvent what TYPO3 already provides. Always verify that the APIs and methods you use exist and are not deprecated in TYPO3 v14 by checking the official TYPO3 documentation.

Sources

This skill is based on 17 authoritative sources:

  1. TYPO3 Workspaces Extension Docs
  2. Versioning (Workspaces Extension)
  3. Creating a Custom Workspace
  4. Configuration Options (Workspaces)
  5. PSR-14 Events (Workspaces)
  6. Versioning & Workspaces (TYPO3 Explained / Core API)
  7. TCA versioningWS Reference
  8. Restriction Builder (TYPO3 Explained / Doctrine DBAL)
  9. b13 Blog: The Elegant Efficiency of TYPO3 Overlays (Benni Mack)
  10. Scheduler Tasks (Workspaces)
  11. Users Guide (Workspaces)
  12. TYPO3-CORE-SA-2025-022: Information Disclosure in Workspaces Module
  13. Localized Content Guide
  14. File Collections (TYPO3 Explained)
  15. FAL Database Architecture
  16. Forge Bug #60343: sys_file_metadata does not recognize workspace
  17. 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:

FieldPurpose
t3ver_oidPoints to the live record's uid (0 for live records and workspace-new records)
t3ver_wsidWorkspace ID this version belongs to (0 for live)
t3ver_stateSpecial state flags (see below)
t3ver_stageWorkflow stage (0=editing, -10=ready to publish)
pidReal page ID (same as the live record's pid)
Note: Before TYPO3 v11, offline versions had pid = -1. Since v11 (Breaking #92497), workspace records store their real pid. If you encounter pid = -1 in legacy code or documentation, it is outdated.

t3ver_state values (TYPO3 v14):

ValueMeaning
0Default: workspace modification of existing live record
1New record created exclusively in workspace (no live counterpart)
2Delete placeholder (record marked for deletion upon publish)
4Move pointer (record to be moved upon publish, stores new pid/sorting)
Removed in v11: t3ver_state = -1 (old "new version" pendant), t3ver_state = 3 (old "move placeholder"), and the t3ver_move_id field. If you see these values in legacy code, they are not used on TYPO3 v14.

The Overlay Mechanism

TYPO3 always fetches live records first, then overlays workspace versions on top. For translations with workspaces, the chain is:

  1. Fetch default language, live record (language=0, workspace=0)
  2. Overlay workspace version (search for t3ver_oid=<uid> AND t3ver_wsid=<current_ws>)
  3. Overlay language translation (search for l10n_parent=<uid> in target language)
  4. 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 v14 still accepts DataHandler version commands with action => 'publish' (preferred) or action => 'swap' (alias handled the same as publish in DataHandlerHook). The parameter key for the workspace version UID remains swapWith, not uid.
  • Do not rely on historic bidirectional workspace “swap modes” on sys_workspace (removed in v11, #92206) — that is separate from the swap keyword in cmdmaps.

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 files inside existing references can still have edge cases in workspace previews; test on your exact TYPO3 minor and prefer new filenames over in-place replacement when possible.

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:

  1. The new PDF is uploaded to fileadmin/ immediately (files are live!)
  2. An external person guesses the URL by incrementing the year
  3. 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)
<IfModule mod_authz_core.c>
    Require all denied
</IfModule>
# NGINX
location /fileadmin/confidential/ {
    deny all;
    return 403;
}

5. Use separate file references per workspace version:

Upload the new file with a different name. Do NOT overwrite the existing file. The workspace version of the content element references the new file, the live version keeps the old one. Upon publishing, both files exist but only the new one is referenced.

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 valueResolution mechanismDatabase 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_mmMM 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 TypeRecord versioned?File binding versioned?Physical files versioned?Workspace-safe?
StaticYesYes (sys_file_reference has versioningWS = true)NoPartially safe
Folder-basedYes (record only)No -- no DB binding existsNoNot safe
Category-basedYesPartially (category MM handled via parent overlay)NoPartially 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:

<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\EventListener;

use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Resource\StorageRepository;
use TYPO3\CMS\Workspaces\Event\AfterRecordPublishedEvent;

#[AsEventListener]
final class CleanupOldCollectionFolderListener
{
    public function __construct(
        private readonly StorageRepository $storageRepository,
    ) {}

    public function __invoke(AfterRecordPublishedEvent $event): void
    {
        if ($event->getTable() !== 'sys_file_collection') {
            return;
        }

        // After publishing a folder-based collection, remove the old folder
        // if it is no longer referenced by any collection.
        // Implementation depends on your naming convention (e.g. gallery-v1/, gallery-v2/).
    }
}

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 (TYPO3 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 extension:setup or your project's equivalent schema-update wrapper)
  • 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 backend sidebar (v14.2+; earlier v14 builds may still show it 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'][<table>]['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)
Inline / IRRE child tables (Deprecation #106821): If versioningWS is missing on a child table, Core logs a deprecation and may auto-add the flag at TCA build time — it does not throw a hard error. Set the flag explicitly in TCA; it will become a hard requirement in v15.
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.<wsId> 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

<?php

declare(strict_types=1);

namespace MyVendor\MySitepackage\Command;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Utility\GeneralUtility;

/**
 * CLI command to create a workspace with base configuration.
 *
 * With `autoconfigure: true` in `Services.yaml`, `#[AsCommand]` is enough — no `console.command` tag needed.
 */
#[AsCommand(
    name: 'workspace:setup',
    description: 'Create a workspace with backend group and base configuration',
)]
final class SetupWorkspaceCommand extends Command
{

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);

        // Step 1: Create backend user group for workspace editors
        $data = [];
        $data['be_groups']['NEW_ws_group'] = [
            'pid' => 0,
            'title' => 'Workspace Editors',
            'description' => 'Backend group for workspace editing access',
            // Grant access to common tables
            'tables_modify' => 'pages,tt_content,sys_file_reference',
            'tables_select' => 'pages,tt_content,sys_file_reference,sys_file',
            // Grant LIVE workspace access
            'workspace_perms' => 1,
            // Page types allowed
            'pagetypes_select' => '1,3,4,6,7,199,254',
            // Explicitly allow content types
            'explicit_allowdeny' => '',
        ];

        $dataHandler = GeneralUtility::makeInstance(DataHandler::class);
        $dataHandler->start($data, []);
        $dataHandler->process_datamap();

        if (!empty($dataHandler->errorLog)) {
            $io->error('Failed to create backend group: ' . implode(', ', $dataHandler->errorLog));
            return Command::FAILURE;
        }

        $groupUid = $dataHandler->substNEWwithIDs['NEW_ws_group'] ?? 0;
        $io->success('Created backend group "Workspace Editors" (uid=' . $groupUid . ')');

        // Step 2: Create the custom workspace
        $data = [];
        $data['sys_workspace']['NEW_workspace'] = [
            'pid' => 0,
            'title' => 'Staging Workspace',
            'description' => 'Content staging workspace for preview and review before publishing',
            'adminusers' => 'be_groups_' . $groupUid,
            'members' => 'be_groups_' . $groupUid,
            // Stages: default stages (Editing, Ready to publish) are always available
            // Publish access: 0 = no restriction, 1 = only publish-stage content, 2 = only owners can publish
            'publish_access' => 0,
            // Allow editing of non-versionable records (live edit)
            // Field name matches Core TCA / DB column (historical spelling "notificaton")
            'edit_allow_notificaton_settings' => 0,
        ];

        $dataHandler = GeneralUtility::makeInstance(DataHandler::class);
        $dataHandler->start($data, []);
        $dataHandler->process_datamap();

        if (!empty($dataHandler->errorLog)) {
            $io->error('Failed to create workspace: ' . implode(', ', $dataHandler->errorLog));
            return Command::FAILURE;
        }

        $wsUid = $dataHandler->substNEWwithIDs['NEW_workspace'] ?? 0;
        $io->success('Created workspace "Staging Workspace" (uid=' . $wsUid . ')');

        $io->section('Next Steps');
        $io->listing([
            'Assign backend users to the "Workspace Editors" group',
            'Configure DB mounts and file mounts on the group',
            'Set up Scheduler tasks: "Workspaces auto-publication" + "Workspaces cleanup preview links"',
            'Configure TSconfig: options.workspaces.previewLinkTTLHours = 48',
            'Test: switch to the workspace in the backend sidebar and edit a page',
        ]);

        return Command::SUCCESS;
    }
}

Run:

# DDEV
ddev exec bin/typo3 workspace:setup

# Non-DDEV
bin/typo3 workspace:setup

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

<?php
// EXT:my_extension/Configuration/TCA/tx_myextension_domain_model_item.php

return [
    'ctrl' => [
        'title' => 'LLL:EXT:my_extension/Resources/Private/Language/locallang_db.xlf:tx_myextension_domain_model_item',
        'label' => 'title',
        'tstamp' => 'tstamp',
        'crdate' => 'crdate',
        'delete' => 'deleted',
        'sortby' => 'sorting',
        // Enable workspace versioning
        'versioningWS' => true,
        // Language support
        'languageField' => 'sys_language_uid',
        'transOrigPointerField' => 'l10n_parent',
        'transOrigDiffSourceField' => 'l10n_diffsource',
        'translationSource' => 'l10n_source',
        'enablecolumns' => [
            'disabled' => 'hidden',
            'starttime' => 'starttime',
            'endtime' => 'endtime',
        ],
        'iconfile' => 'EXT:my_extension/Resources/Public/Icons/item.svg',
    ],
    // ... columns, types, palettes
];

The t3ver_* database columns are auto-created when versioningWS = true -- you do NOT need to add them to ext_tables.sql. Similarly, enablecolumns fields (hidden, starttime, endtime) and language fields (sys_language_uid, l10n_parent, l10n_diffsource) get auto-created TCA column definitions from ctrl on TYPO3 v14 -- you do NOT need to define them in 'columns'.

After enabling, run:

bin/typo3 extension:setup

Step 2: Disable Versioning for Specific Tables

If a table must NOT be versioned (e.g., logging, statistics):

<?php
// EXT:my_extension/Configuration/TCA/Overrides/tx_myextension_log.php

$GLOBALS['TCA']['tx_myextension_log']['ctrl']['versioningWS'] = false;

Step 3: Allow Live Editing in Workspaces

For tables that should always be edited live (e.g., user settings, configuration):

<?php
// EXT:my_extension/Configuration/TCA/Overrides/tx_myextension_settings.php

// Records of this table are edited live even when a workspace is active
$GLOBALS['TCA']['tx_myextension_settings']['ctrl']['versioningWS_alwaysAllowLiveEdit'] = true;

Step 4: Backend Overlay (REQUIRED for Backend Modules)

<?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;
    }
}

Step 5: Frontend Overlay (REQUIRED for Frontend Plugins)

<?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;
    }
}

Step 6: Backend Module Workspace Restriction

<?php
// EXT:my_extension/Configuration/Backend/Modules.php

return [
    'my_module' => [
        // v14: primary web modules use parent `content`; compare with Core `Configuration/Backend/Modules.php`
        'parent' => 'content',
        'position' => ['after' => 'records'],
        'access' => 'user',
        // Control workspace availability:
        // '*'       = available in all workspaces (default)
        // 'live'    = only available in live workspace
        // 'offline' = only available in custom workspaces
        'workspaces' => '*',
        'iconIdentifier' => 'my-module-icon',
        'labels' => 'LLL:EXT:my_extension/Resources/Private/Language/locallang_mod.xlf',
        'routes' => [
            '_default' => [
                'target' => \MyVendor\MyExtension\Controller\MyModuleController::class . '::handleRequest',
            ],
        ],
    ],
];

6. Migrating Queries: WITHOUT Workspaces to WITH Workspaces

Pattern A: Backend QueryBuilder (Before/After)

BEFORE (no workspace awareness):

<?php
// WRONG: Does not respect workspaces
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tt_content');
$rows = $queryBuilder
    ->select('*')
    ->from('tt_content')
    ->where(
        $queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT))
    )
    ->executeQuery()
    ->fetchAllAssociative();

// Rows may include workspace placeholders and miss workspace versions!

AFTER (workspace-aware):

<?php

use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Database\Connection;

$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tt_content');
$result = $queryBuilder
    ->select('*')
    ->from('tt_content')
    ->where(
        $queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT))
    )
    ->executeQuery();

$rows = [];
while ($row = $result->fetchAssociative()) {
    // Apply workspace overlay
    BackendUtility::workspaceOL('tt_content', $row);
    if (is_array($row)) {
        $rows[] = $row;
    }
}

Pattern B: Frontend with WorkspaceRestriction

BEFORE (no workspace restriction):

<?php
// WRONG for frontend: Missing WorkspaceRestriction
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_news_domain_model_news');
$queryBuilder->getRestrictions()->removeAll()->add(
    GeneralUtility::makeInstance(DeletedRestriction::class)
);

AFTER (proper frontend restrictions):

<?php

use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;

// FrontendRestrictionContainer includes:
// - DeletedRestriction
// - WorkspaceRestriction  <-- automatically included!
// - HiddenRestriction
// - StartTimeRestriction
// - EndTimeRestriction
// - FrontendGroupRestriction
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_news_domain_model_news');
$queryBuilder->setRestrictions(
    GeneralUtility::makeInstance(FrontendRestrictionContainer::class)
);

Pattern C: Adding WorkspaceRestriction Manually

<?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: 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.

**CRITICAL: Extbase DataMapper does NOT populate t3ver_* system fields. If you add $t3verWsid, $t3verState, or $t3verOid properties to an Extbase domain model, they will always remain at their default value** (typically 0). The t3ver_* columns are ctrl fields in TCA, not columns — Extbase's DataMapFactory does not create column maps for them. To inspect workspace state of Extbase model objects, query the database directly via ConnectionPool: ``php $row = GeneralUtility::makeInstance(ConnectionPool::class) ->getConnectionForTable('pages') ->select(['t3ver_wsid', 't3ver_state', 't3ver_oid'], 'pages', ['uid' => $post->getUid()]) ->fetchAssociative(); ``

However, if you use custom QueryBuilder inside a repository:

<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Domain\Repository;

use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
use TYPO3\CMS\Extbase\Persistence\Repository;

final class ItemRepository extends Repository
{
    public function __construct(
        private readonly ConnectionPool $connectionPool,
        private readonly PageRepository $pageRepository,
    ) {
        parent::__construct();
    }

    /**
     * Custom query that needs manual workspace handling.
     */
    public function findItemsWithCustomQuery(int $categoryId): array
    {
        $queryBuilder = $this->connectionPool
            ->getQueryBuilderForTable('tx_myextension_domain_model_item');

        // Use FrontendRestrictionContainer for proper workspace filtering
        $queryBuilder->setRestrictions(
            \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(
                \TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer::class
            )
        );

        $result = $queryBuilder
            ->select('i.*')
            ->from('tx_myextension_domain_model_item', 'i')
            ->join(
                'i',
                'sys_category_record_mm',
                'mm',
                $queryBuilder->expr()->eq('mm.uid_foreign', $queryBuilder->quoteIdentifier('i.uid'))
            )
            ->where(
                $queryBuilder->expr()->eq(
                    'mm.uid_local',
                    $queryBuilder->createNamedParameter($categoryId, \TYPO3\CMS\Core\Database\Connection::PARAM_INT)
                )
            )
            ->executeQuery();

        $items = [];
        while ($row = $result->fetchAssociative()) {
            $this->pageRepository->versionOL('tx_myextension_domain_model_item', $row);
            if (is_array($row)) {
                $items[] = $row;
            }
        }

        return $items;
    }
}

Pattern E: Migrating legacy queries (TYPO3 v10/v11 style to TYPO3 v14)

BEFORE (deprecated -- old exec_SELECTquery pattern, no longer available):

<?php
// DEPRECATED: Do NOT use. Not available on TYPO3 v14.
// $res = $GLOBALS['TYPO3_DB']->exec_SELECTquery('*', 'tt_content', 'pid=' . (int)$pageId);

AFTER (modern QueryBuilder with workspace support):

<?php

use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;

$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
    ->getQueryBuilderForTable('tt_content');

$result = $queryBuilder
    ->select('*')
    ->from('tt_content')
    ->where(
        $queryBuilder->expr()->eq(
            'pid',
            $queryBuilder->createNamedParameter($pageId, Connection::PARAM_INT)
        )
    )
    ->executeQuery();

while ($row = $result->fetchAssociative()) {
    BackendUtility::workspaceOL('tt_content', $row);
    if (is_array($row)) {
        // Process the workspace-overlaid record
    }
}

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:

  1. The default language record is versioned first (if modified)
  2. Translations created in a workspace are single records with t3ver_state = 1 (workspace-new)
  3. The l10n_parent field points to the live default language record UID
  4. Modified translations have t3ver_oid pointing to the live translation UID

Database example for a tt_content record translated to French in workspace 1:

uidpidt3ver_wsidt3ver_oidt3ver_statel10n_parentsys_language_uidheader
112000000Article #1
3120101111Article #1 (fr)
Note: On TYPO3 v14, workspace-new records are a single row with t3ver_state = 1 and real pid. The old two-row pattern (placeholder + version with pid = -1 and t3ver_state = -1) was removed in v11.

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:

#CheckHow
1Extension installed?composer show typo3/cms-workspaces
2Workspace record exists?List module on root page, filter System Records
3User has workspace access?Backend user/group > Mounts and Workspaces tab
4User has LIVE access?Same tab, "Live" checkbox must be checked
5Table is versioningWS?Check $GLOBALS['TCA'][<table>]['ctrl']['versioningWS']
6DB columns exist?DESCRIBE <table> -- look for t3ver_oid, t3ver_wsid, t3ver_state
7DB mounts correct?User/group must have DB mounts covering the pages being edited
8File mounts correct?User/group must have file mounts for media access
9Schema up to date?bin/typo3 extension:setup (or your project's schema-update wrapper)
10Cache 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

<?php

declare(strict_types=1);

use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Utility\GeneralUtility;

// Get current workspace ID
$workspaceId = GeneralUtility::makeInstance(Context::class)
    ->getPropertyFromAspect('workspace', 'id', 0);

// Table modify permission (does not replace full workspace record ACL — use FormEngine/DataHandler for authoritative checks)
$mayModifyTable = $GLOBALS['BE_USER']->check('tables_modify', 'tt_content');

// Creating new records in workspace: BackendUserAuthentication API name is workspaceCanCreateNewRecord(string $table): bool (no $pageId)
$canCreate = $GLOBALS['BE_USER']->workspaceCanCreateNewRecord('tt_content');

// Workspace membership / role for the current user
$workspaceAccess = $GLOBALS['BE_USER']->checkWorkspace($workspaceId);
// Returns array|false — `_ACCESS` may be `admin`, `owner`, `member`, `online`, or `false` depending on context (see Core source for your minor)

Common Issues & Solutions

IssueCauseSolution
Records not visible in workspaceversioningWS = false on tableSet versioningWS = true, then run extension:setup (or your project's schema-update wrapper)
Workspace changes visible on live siteMissing WorkspaceRestriction in custom queryAdd FrontendRestrictionContainer or manual WorkspaceRestriction
"Editing not possible" errorUser lacks edit permission or stage accessCheck user/group tables_modify, DB mounts, workspace membership
Preview shows wrong contentMissing versionOL() call in extensionAdd BackendUtility::workspaceOL() or PageRepository->versionOL() after query
Publish does nothingContent not in "Ready to publish" stageAdvance content through stages, or disable stage restriction
File changed in all workspacesFiles are not versioned (by design)Upload new files with unique names instead of overwriting
Translation missing in workspaceLanguage not allowed in workspaceSet options.workspaces.allowed_languages.<wsId> TSconfig
Auto-publish not workingScheduler task not configured or not runningCreate "Workspaces auto-publication" task, verify cron job
Workspace selector not visibleUser has no workspace accessAssign 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"
For TYPO3 v14 use typo3/testing-framework:"^9.0" (see typo3-testing skill for details).

Step 2: Create PHPUnit configuration for functional tests

Create Build/phpunit-functional.xml in your extension root:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/typo3/testing-framework/Resources/Core/Build/FunctionalTestsBootstrap.php"
         colors="true"
         cacheResult="false">
    <testsuites>
        <testsuite name="Functional">
            <directory>Tests/Functional</directory>
        </testsuite>
    </testsuites>
    <php>
        <!-- Functional tests need a database. DDEV provides one automatically. -->
        <!-- For non-DDEV: set typo3DatabaseHost, typo3DatabaseUsername, etc. -->
        <!-- Example for DDEV (auto-detected, usually no env vars needed): -->
        <!-- <env name="typo3DatabaseHost" value="db"/> -->
        <!-- <env name="typo3DatabaseUsername" value="db"/> -->
        <!-- <env name="typo3DatabasePassword" value="db"/> -->
        <!-- <env name="typo3DatabaseName" value="db"/> -->
    </php>
</phpunit>

Step 3: Create directory structure

mkdir -p Tests/Functional/Fixtures
your-extension/
├── Build/
│   └── phpunit-functional.xml       ← PHPUnit config
├── Classes/
│   └── ...
├── Configuration/
│   └── TCA/
├── Tests/
│   └── Functional/
│       ├── Fixtures/
│       │   └── WorkspaceTestData.csv ← Test data
│       └── WorkspaceAwareTest.php    ← Test class
├── composer.json
└── ext_emconf.php

Step 4: Ensure composer.json has autoload-dev

{
    "autoload-dev": {
        "psr-4": {
            "MyVendor\\MyExtension\\Tests\\": "Tests/"
        }
    }
}

Then run:

# DDEV
ddev composer dump-autoload

# Non-DDEV
composer dump-autoload

Functional Test Setup

<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Tests\Functional;

use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Context\WorkspaceAspect;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;

final class WorkspaceAwareTest extends FunctionalTestCase
{
    /**
     * Load the workspaces extension for all tests in this class.
     */
    protected array $coreExtensionsToLoad = [
        'workspaces',
    ];

    /**
     * Load your own extension.
     */
    protected array $testExtensionsToLoad = [
        'typo3conf/ext/my_extension',
    ];

    protected function setUp(): void
    {
        parent::setUp();

        // Import base data: pages, content, workspace record, backend user
        $this->importCSVDataSet(__DIR__ . '/Fixtures/WorkspaceTestData.csv');

        // Initialize backend user (uid=1 from fixture, must be admin for DataHandler)
        $this->setUpBackendUser(1);
        Bootstrap::initializeLanguageObject();
    }

    /**
     * Helper: set the current workspace context.
     */
    private function setWorkspaceId(int $workspaceId): void
    {
        $context = GeneralUtility::makeInstance(Context::class);
        $context->setAspect('workspace', new WorkspaceAspect($workspaceId));
        $GLOBALS['BE_USER']->setWorkspace($workspaceId);
    }

    /**
     * Test: Record created in workspace is NOT visible in live.
     */
    public function testRecordCreatedInWorkspaceNotVisibleInLive(): void
    {
        // Switch to workspace 1
        $this->setWorkspaceId(1);

        // Create a record in the workspace via DataHandler
        $data = [
            'tt_content' => [
                'NEW_1' => [
                    'pid' => 1,
                    'CType' => 'text',
                    'header' => 'Workspace Only Content',
                    'bodytext' => '<p>This should not be live</p>',
                ],
            ],
        ];

        $dataHandler = GeneralUtility::makeInstance(DataHandler::class);
        $dataHandler->start($data, []);
        $dataHandler->process_datamap();

        self::assertEmpty($dataHandler->errorLog, 'DataHandler errors: ' . implode(', ', $dataHandler->errorLog));

        // Switch back to LIVE
        $this->setWorkspaceId(0);

        // Query live records -- the workspace record should NOT appear
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
            ->getQueryBuilderForTable('tt_content');

        $liveRecords = $queryBuilder
            ->select('uid', 'header')
            ->from('tt_content')
            ->where(
                $queryBuilder->expr()->eq('pid', 1),
                $queryBuilder->expr()->eq('t3ver_wsid', 0),
                $queryBuilder->expr()->neq('t3ver_state', 1) // exclude new placeholders
            )
            ->executeQuery()
            ->fetchAllAssociative();

        $headers = array_column($liveRecords, 'header');
        self::assertNotContains('Workspace Only Content', $headers);
    }

    /**
     * Test: Workspace overlay returns modified content.
     */
    public function testWorkspaceOverlayReturnsModifiedContent(): void
    {
        // Record uid=10 exists in fixture with header "Original Header"
        // Workspace version exists with header "Modified In Workspace"
        $this->setWorkspaceId(1);

        $row = \TYPO3\CMS\Backend\Utility\BackendUtility::getRecordWSOL('tt_content', 10);

        self::assertIsArray($row);
        self::assertSame('Modified In Workspace', $row['header']);
    }

    /**
     * Test: Workspace publish command makes staged content live for a record pair.
     *
     * Publish live tt_content uid=10 using the workspace version row (swapWith = offline version uid).
     */
    public function testPublishWorkspaceRecordPairMakesContentLive(): void
    {
        $this->setWorkspaceId(1);

        // Publish workspace record for tt_content uid=10
        $cmd = [
            'tt_content' => [
                10 => [
                    'version' => [
                        'action' => 'publish',
                        'swapWith' => $this->getWorkspaceVersionUid('tt_content', 10, 1),
                    ],
                ],
            ],
        ];

        $dataHandler = GeneralUtility::makeInstance(DataHandler::class);
        $dataHandler->start([], $cmd);
        $dataHandler->process_cmdmap();

        self::assertEmpty($dataHandler->errorLog);

        // Switch to LIVE and verify
        $this->setWorkspaceId(0);
        $row = \TYPO3\CMS\Backend\Utility\BackendUtility::getRecord('tt_content', 10);

        self::assertSame('Modified In Workspace', $row['header']);
    }

    /**
     * Helper: Get the uid of the workspace version of a record.
     */
    private function getWorkspaceVersionUid(string $table, int $liveUid, int $workspaceId): int
    {
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
            ->getQueryBuilderForTable($table);
        $queryBuilder->getRestrictions()->removeAll();

        $row = $queryBuilder
            ->select('uid')
            ->from($table)
            ->where(
                $queryBuilder->expr()->eq('t3ver_oid', $liveUid),
                $queryBuilder->expr()->eq('t3ver_wsid', $workspaceId),
                $queryBuilder->expr()->eq('deleted', 0)
            )
            ->executeQuery()
            ->fetchAssociative();

        return (int)($row['uid'] ?? 0);
    }
}

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","<p>Original</p>",0,0,0,0
,11,1,"Modified In Workspace","text","<p>Modified</p>",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:

ErrorCauseFix
Table 'sys_workspace' doesn't existworkspaces not in $coreExtensionsToLoadAdd 'workspaces' to array
Table 'be_users' has no column 'workspace_perms'Schema not created for test DBEnsure workspaces is loaded before setUp()
Access denied for userWrong DB credentialsCheck env vars or DDEV status (ddev describe)
Call to undefined method setUpBackendUserWrong testing-framework versionUse typo3/testing-framework ^9.0 on TYPO3 v14
Record not found after DataHandlerCSV fixture malformedVerify 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)

  1. Editor switches to the custom workspace via the backend sidebar selector (v14.2+)
  2. Editor navigates to the page and edits content elements
  3. TYPO3 automatically creates workspace versions of modified records
  4. Editor previews via "View webpage" button (shows workspace version)
  5. Editor sends changes to "Ready to publish" stage
  6. Reviewer approves or sends back with comments
  7. 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):

  1. Prepare all campaign content in the workspace
  2. Send changes to review and approval stage
  3. Publish (or schedule auto-publish) at campaign start
  4. Create a follow-up workspace change set to restore baseline content after campaign end
  5. 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

  1. Set a "Publish" date on the workspace record (Publishing tab)
  2. Create Scheduler task: "Workspaces auto-publication"
  3. Set task frequency (e.g., every 15 minutes)
  4. 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 advisories are published periodically (SA-2025-022 affected TYPO3 9.0–13.4.17; v14.0.0+ shipped with the fix)

11. PSR-14 Events Reference

AfterRecordPublishedEvent

Fired after a record has been published from a workspace to live.

<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\EventListener;

use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Workspaces\Event\AfterRecordPublishedEvent;

#[AsEventListener]
final class AfterPublishListener
{
    public function __invoke(AfterRecordPublishedEvent $event): void
    {
        $table = $event->getTable();
        $liveId = $event->getRecordId();

        // Example: Clear external CDN cache after publishing
        if ($table === 'pages') {
            // Trigger CDN purge for the published page
        }

        // Example: Notify external system
        if ($table === 'tx_news_domain_model_news') {
            // Send webhook to newsletter system
        }
    }
}

SortVersionedDataEvent

Fired after sorting data in the Workspaces backend module. Use to apply custom sorting.

<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\EventListener;

use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Workspaces\Event\SortVersionedDataEvent;

#[AsEventListener]
final class CustomWorkspaceSortListener
{
    public function __invoke(SortVersionedDataEvent $event): void
    {
        // Custom sorting logic for workspace module
        $data = $event->getData();
        // ... modify $data ...
        $event->setData($data);
    }
}

Key Events

EventWhen Fired
AfterCompiledCacheableDataForWorkspaceEventAfter compiling cacheable workspace version data
AfterDataGeneratedForWorkspaceEventAfter generating all workspace version data
AfterRecordPublishedEventAfter a record is published to live
GetVersionedDataEventAfter preparing/cleaning workspace version data
IsReferenceConsideredForDependencyEventWhen evaluating whether a reference is considered a workspace dependency
ModifyVersionDifferencesEventWhen computing diffs between live and workspace version
RetrievedPreviewUrlEventWhen adjusting the preview URL for a workspace record
SortVersionedDataEventAfter sorting workspace version data in the module

All events are in the \TYPO3\CMS\Workspaces\Event\ namespace.

v14-Only Changes

The following workspace changes apply exclusively to TYPO3 v14.

"Freeze Editing" Removed [v14 only]

Workspace "Freeze Editing" feature has been removed (#107323). Workspaces can no longer be frozen to prevent editing. Remove any code that references freezeEditingWorkspace or relies on frozen workspace state.

Workspace-Aware Inline Child Tables [v14 only]

All inline (IRRE) child tables used in workspace-enabled parent tables should have 'versioningWS' => true in their TCA ctrl (#106821). On TYPO3 v14, a missing flag triggers a deprecation and Core may auto-add it at TCA compile time — set the flag explicitly in extensions now; it becomes a hard requirement in v15.

Workspace Selector Moved to Sidebar [v14.2+ only]

The workspace selector has been moved from the top bar to the backend sidebar. Workspaces now support color and description fields for visual distinction in the sidebar selector. Update any documentation or screenshots referencing the top bar workspace switcher.

Editor Information in Publish Module [v14 only]

The workspace "Publish" module now shows editor information (#106074) — which backend user last modified each record. This helps reviewers identify who made changes.

swap vs publish in cmdmaps [v14]

DataHandlerHook treats swap and publish as aliases — both call version_swap(). Prefer action => 'publish' in new code. The workspace version UID must always be passed as swapWith (Core reads (int)$value['swapWith']):

// ✅ Always pass the offline/workspace version uid as swapWith (live record id is the cmdmap key)
$cmd['tt_content'][10]['version'] = [
    'action' => 'publish',
    'swapWith' => $workspaceVersionUid,
];

// 'swap' still works as an alias but publish is clearer
$cmd['tt_content'][10]['version'] = ['action' => 'swap', 'swapWith' => $workspaceVersionUid];

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)

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.

Source: https://github.com/dirnbauer/webconsulting-skills

平台分布

Codex

38.13%
按下载量换算122

Claude

28.01%
按下载量换算90

Cursor

19.35%
按下载量换算62

Gemini CLI

9.72%
按下载量换算31

安全审计

Gen Agent Trust Hub

通过

Socket

通过

Snyk

通过

权限和风险

需要联网

该 Skill 可能需要联网访问来源站点、仓库或外部 API;具体网络访问范围需要结合源码和 README 复核。

安装前确认

本站仅展示第三方公开信息,不托管安装包,不提供自动安装或运行环境。安装前应自行审查源码、依赖和命令行为。目前只有一个公开来源,建议同时核对源仓库。

继续浏览同类 Skills