Files
VITEC-website/packages/vitec/Documentation/HeadlessIntegration.md
2026-05-18 14:46:25 +02:00

9.4 KiB

Headless Integration Guide for TYPO3 13 Custom Plugins

This guide explains how to integrate a custom TYPO3 Extbase plugin with the TYPO3 Headless extension to provide JSON API output.

The Challenge

When integrating custom plugins with TYPO3 Headless, you face two main obstacles:

  1. Lost Context: Headless creates new ContentObjectRenderer contexts when rendering JSON fields, causing the content element context to be lost. Standard methods like $conf['parentObj'] or $GLOBALS['TSFE']->cObj->data will only contain page data, not the content element.

  2. Extbase Limitations: Extbase repositories don't work in UserFunc context because the full Extbase framework isn't bootstrapped during headless JSON rendering.

The Solution

Step 1: Override Headless TypoScript in Site Set

Create or modify your site set's setup.typoscript file to add custom fields to the headless JSON output:

File: packages/yourext/Configuration/Sets/YourSetName/setup.typoscript

# Override headless rendering for list plugins
tt_content.list.fields.content.fields.yourCustomField = USER
tt_content.list.fields.content.fields.yourCustomField {
    userFunc = Vendor\YourExt\UserFunc\YourJsonRenderer->render
}

Why in site set? Site sets load AFTER headless TypoScript, allowing you to override the default configuration.

Step 2: Create a UserFunc Renderer

Create a UserFunc class that will render your plugin data as JSON:

File: packages/yourext/Classes/UserFunc/YourJsonRenderer.php

<?php

declare(strict_types=1);

namespace Vendor\YourExt\UserFunc;

use Doctrine\DBAL\ParameterType;
use TYPO3\CMS\Core\Service\FlexFormService;
use TYPO3\CMS\Core\Utility\GeneralUtility;

class YourJsonRenderer
{
    public function render(string $content, array $conf): string
    {
        // Step 1: Query tt_content to find the plugin configuration
        $pageId = (int)($GLOBALS['TSFE']->id ?? 0);
        
        $queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
            ->getQueryBuilderForTable('tt_content');
        
        $contentElements = $queryBuilder
            ->select('*')
            ->from('tt_content')
            ->where(
                $queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter($pageId, ParameterType::INTEGER)),
                $queryBuilder->expr()->eq('list_type', $queryBuilder->createNamedParameter('yourext_pluginname', ParameterType::STRING)),
                $queryBuilder->expr()->eq('deleted', 0),
                $queryBuilder->expr()->eq('hidden', 0)
            )
            ->executeQuery()
            ->fetchAllAssociative();
        
        if (empty($contentElements)) {
            return json_encode([]);
        }
        
        $contentElement = $contentElements[0];
        
        // Step 2: Parse FlexForm settings
        $flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
        $flexFormData = $flexFormService->convertFlexFormContentToArray($contentElement['pi_flexform'] ?? '');
        $settings = $flexFormData['settings'] ?? [];
        
        // Step 3: Use direct database queries (NOT Extbase repositories)
        $dataQueryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
            ->getQueryBuilderForTable('tx_yourext_domain_model_yourmodel');
        
        $records = $dataQueryBuilder
            ->select('*')
            ->from('tx_yourext_domain_model_yourmodel')
            ->where(
                $dataQueryBuilder->expr()->eq('deleted', 0),
                $dataQueryBuilder->expr()->eq('hidden', 0)
                // Add your filters based on $settings
            )
            ->executeQuery()
            ->fetchAllAssociative();
        
        // Step 4: Serialize to JSON
        $data = [];
        foreach ($records as $record) {
            $data[] = [
                'uid' => (int)$record['uid'],
                'title' => $record['title'] ?? '',
                // Add more fields as needed
            ];
        }
        
        return json_encode($data);
    }
}

Step 3: Using FlexForm Settings

You can access any FlexForm field from your plugin configuration:

// Parse FlexForm
$flexFormService = GeneralUtility::makeInstance(FlexFormService::class);
$flexFormData = $flexFormService->convertFlexFormContentToArray($contentElement['pi_flexform'] ?? '');
$settings = $flexFormData['settings'] ?? [];

// Access specific settings
$categoryUids = array_filter(
    array_map('intval', explode(',', (string)($settings['categories'] ?? '')))
);
$debugMode = (bool)($settings['debug'] ?? false);

// Use settings in your query
if (!empty($categoryUids)) {
    // Add filtering based on categories
}

Conditional Debug Output

You can add debug information based on a FlexForm checkbox:

// At the end of your render method:
if ($debugMode) {
    return json_encode([
        'products' => $data,
        'debug' => [
            'pageId' => $pageId,
            'categoryUids' => $categoryUids,
            'recordCount' => count($data),
            'settings' => $settings  // Show all settings for troubleshooting
        ]
    ]);
}

// Return just the data array when debug is off
return json_encode($data);

This allows you to toggle debug information on/off directly in the backend without code changes.

Step 4: Important Notes

Use Doctrine ParameterType (TYPO3 13)

In TYPO3 13, always use Doctrine\DBAL\ParameterType instead of \PDO::PARAM_*:

use Doctrine\DBAL\ParameterType;

// Correct for TYPO3 13:
$queryBuilder->createNamedParameter($value, ParameterType::INTEGER)
$queryBuilder->createNamedParameter($value, ParameterType::STRING)

// Wrong (deprecated):
$queryBuilder->createNamedParameter($value, \PDO::PARAM_INT)

Why Direct Database Queries?

Don't use:

// This won't work in UserFunc context
$repository = GeneralUtility::makeInstance(YourRepository::class);
$records = $repository->findAll(); // Returns empty!

Use instead:

// Direct database query
$queryBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\ConnectionPool::class)
    ->getQueryBuilderForTable('tx_yourext_domain_model_yourmodel');
$records = $queryBuilder->select('*')->from('tx_yourext_domain_model_yourmodel')->...

Step 5: Clear Cache

After making changes, always clear the TYPO3 cache:

./vendor/bin/typo3 cache:flush

Testing

Test your JSON output using curl and jq:

# Check if your field appears
curl -s "https://yourdomain.com/your-page" | jq '.content.colPos0[] | select(.type=="your_plugin") | .content'

# Check specific field
curl -s "https://yourdomain.com/your-page" | jq '.content.colPos0[] | select(.type=="your_plugin") | .content.yourCustomField'

# With debug mode enabled in backend, you'll see:
curl -s "https://yourdomain.com/your-page" | jq '.content.colPos0[] | select(.type=="your_plugin") | .content.yourCustomField.debug'

Example Output

Without debug mode:

[
  {
    "uid": 1,
    "title": "Product Name",
    "slug": "product-name"
  }
]

With debug mode enabled (checkbox in backend):

{
  "products": [
    {
      "uid": 1,
      "title": "Product Name",
      "slug": "product-name"
    }
  ],
  "debug": {
    "pageId": 5,
    "categoryUids": [8],
    "productCount": 1,
    "settings": {
      "debug": "1",
      "categories": "8",
      "layout": "0"
    }
  }
}

Example: Real-World Implementation

See Classes/UserFunc/ProductListJsonRenderer.php for a complete working example that:

  • Queries tt_content for plugin configuration
  • Parses FlexForm settings (including debug mode, categories)
  • Queries products with visibility filters using direct database queries
  • Provides conditional debug output based on FlexForm settings
  • Serializes product data to JSON with backward compatibility

Common Issues

Empty Results

  • Problem: UserFunc returns empty array
  • Solution: Check that you're using direct database queries, not Extbase repositories

Parameter Type Errors

  • Problem: createNamedParameter(): Argument #2 ($type) must be of type Doctrine\DBAL\ParameterType
  • Solution: Use ParameterType::INTEGER instead of \PDO::PARAM_INT

Content Element Not Found

  • Problem: tt_content query returns empty
  • Solution: Verify the list_type value matches your plugin signature exactly

Wrong Context Data

  • Problem: Trying to access content element via $GLOBALS['TSFE']->cObj->data
  • Solution: This only contains page data. Query tt_content directly instead

Architecture Summary

1. Browser requests JSON page
   ↓
2. Headless extension renders page as JSON
   ↓
3. Encounters tt_content.list (your plugin)
   ↓
4. Executes your TypoScript override
   ↓
5. Calls your UserFunc
   ↓
6. UserFunc queries tt_content for plugin config
   ↓
7. UserFunc queries database directly for records
   ↓
8. Returns JSON array
   ↓
9. Headless includes it in final JSON output

Conclusion

The key insight is that headless rendering requires a different approach than normal Extbase plugins:

  • Use site sets for TypoScript overrides
  • Use UserFuncs for custom rendering
  • Use direct database queries instead of repositories
  • Query tt_content to recover plugin configuration

This approach provides full control over JSON output while working within the constraints of the headless rendering context.