Extbase quick start for experienced developers 

You know TYPO3, you know PHP, you just want the steps. This page gets a minimal but fully working Extbase extension in front of you as quickly as possible — a list and detail view for a custom record type, registered as a frontend plugin.

For the reasoning behind each step, follow the links into the relevant chapters.

Step 1: Scaffold the extension 

Use the FriendsOfTYPO3 kickstarter package to generate the extension skeleton:

In your project root
composer require friendsoftypo3/kickstarter --dev
vendor/bin/typo3 make:extension
Copied!

Answer the prompts (vendor name, extension key, etc.). The kickstarter generates the directory structure, composer.json and the boilerplate files you need. Since TYPO3 v14 you do not need ext_emconf.php unless you plan to publish your extension to the TYPO3 Extension Repository.

Step 2: Create the domain model 

Add a class extending \TYPO3\CMS\Extbase\DomainObject\AbstractEntity to Classes/Domain/Model/. Properties map to database columns by name.

EXT:my_extension/Classes/Domain/Model/Conference.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Domain\Model;

use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;

class Conference extends AbstractEntity
{
    protected string $title = '';
    protected string $description = '';
    protected ?\DateTimeImmutable $conferenceDate = null;

    public function getTitle(): string
    {
        return $this->title;
    }

    public function getDescription(): string
    {
        return $this->description;
    }

    public function getEventDate(): ?\DateTimeImmutable
    {
        return $this->conferenceDate;
    }
}
Copied!

Key points:

  • Declare properties protected. Public properties also work and can keep the model shorter (no getters/setters), but protected keeps the door open for getter/setter logic and makes lazy-loaded relations easier to reason about. Private properties are never populated by Extbase — use protected, not private.
  • Do not initialise properties in the constructor. Extbase populates them directly when loading objects from the database, bypassing the constructor.
  • Use typed properties. Extbase reads the type declarations to map values correctly.

Step 3: Create the repository 

For a basic repository, extending \TYPO3\CMS\Extbase\Persistence\Repository is all you need. The naming convention is mandatory: a model named Conference must have a repository named ConferenceRepository in the \Domain\Repository namespace.

EXT:my_extension/Classes/Domain/Repository/ConferenceRepository.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Domain\Repository;

class ConferenceRepository extends \TYPO3\CMS\Extbase\Persistence\Repository {}
Copied!

The base class provides findAll(), findByUid(), findBy(array $criteria), and findOneBy(array $criteria) out of the box.

Step 4: Define the database table (TCA) 

Create Configuration/TCA/tx_myextension_domain_model_conference.php with the column definitions matching your model properties.

Since TYPO3 v13, database columns are auto-created from TCA definitions — you no longer need to define every field in ext_tables.sql. Check the database analyser after installation to confirm the generated schema matches your expectations. If a column needs a non-default type or index, declare it explicitly in ext_tables.sql and it will take precedence.

The TCA column names must match the property names of your model (camelCase properties map to snake_case columns by default — for example $conferenceDate maps to conference_date).

Step 5: Create the controller 

Controllers live in Classes/Controller/ and extend \TYPO3\CMS\Extbase\Mvc\Controller\ActionController . Each public method ending in Action is automatically available as a plugin action. Use dependency injection to receive dependencies via the constructor. In Extbase, repositories and other services are injected this way — see also Injecting repositories with dependency injection.

EXT:my_extension/Classes/Controller/ConferenceController.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Controller;

use MyVendor\MyExtension\Domain\Model\Conference;
use MyVendor\MyExtension\Domain\Repository\ConferenceRepository;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;

class ConferenceController extends ActionController
{
    public function __construct(
        protected readonly ConferenceRepository $conferenceRepository,
    ) {}

    public function listAction(): ResponseInterface
    {
        $this->view->assign('conferences', $this->conferenceRepository->findAll());
        return $this->htmlResponse();
    }

    public function showAction(
        Conference $conference,
    ): ResponseInterface {
        $this->view->assign('conference', $conference);
        return $this->htmlResponse();
    }
}
Copied!
  • Assign variables to the view with $this->view->assign().
  • Return $this->htmlResponse() to render the Fluid template.
  • Typed action arguments are automatically resolved from the request — passing a UID in the URL results in a fully populated Conference object in the action. Extbase loads it from the repository for you.

Step 6: Add Fluid templates 

Create the template files Extbase expects by convention:

  • EXT:my_extension/Resources/Private/

    • Templates/

      • Conference/

        • List.fluid.html
        • Show.fluid.html
    • Layouts/

      • Default.fluid.html
    • Partials/

The template name matches the action name — for example listAction() maps to List.fluid.html. Variables assigned in the controller are available directly in the template.

EXT:my_extension/Resources/Private/Templates/Conference/List.fluid.html
<f:for each="{conferences}" as="conference">
    <h2>{conference.title}</h2>
    <p>{conference.conferenceDate -> f:format.date(format: 'd.m.Y')}</p>
    <f:link.action action="show" arguments="{conference: conference}">
        Read more
    </f:link.action>
</f:for>
Copied!

Step 7: Register the plugin 

Two calls are required — one in ext_localconf.php, one in Configuration/TCA/Overrides/tt_content.php.

Since TYPO3 v14, plugins are registered as dedicated content types ( CType).

ext_localconf.php tells Extbase which controller actions the plugin may call:

EXT:my_extension/ext_localconf.php
<?php

declare(strict_types=1);

use MyVendor\MyExtension\Controller\ConferenceController;
use TYPO3\CMS\Extbase\Utility\ExtensionUtility;

defined('TYPO3') or die();

ExtensionUtility::configurePlugin(
    'MyExtension',
    'ConferenceList',
    [
        ConferenceController::class => ['list', 'show'],
    ],
);
Copied!

Configuration/TCA/Overrides/tt_content.php registers the plugin as a content element in the backend:

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

declare(strict_types=1);

use TYPO3\CMS\Extbase\Utility\ExtensionUtility;

defined('TYPO3') or die();

ExtensionUtility::registerPlugin(
    'MyExtension',
    'ConferenceList',
    'LLL:EXT:my_extension/Resources/Private/Language/locallang.xlf:plugin.conferencelist.title',
    'EXT:my_extension/Resources/Public/Icons/Extension.svg',
);
Copied!

Step 9: Install and try it 

Install your extension if it is not already active. In a Composer-based project, require it first:

In your project root
composer require myvendor/my-extension
Copied!

In non-composer-based projects, the extension is already in place, as you develop it within the code base. In both cases, you need to activate it:

In your project root
vendor/bin/typo3 extension:activate my_extension
Copied!

Then in the TYPO3 backend:

  1. Create a sysfolder page and add your conference records there.
  2. Create or edit a regular page, add a content element, and select your plugin from the content element type list.
  3. Set the Record Storage Page on the plugin content element to the sysfolder from step 1 (or configure plugin.tx_myextension.persistence.storagePid in TypoScript).
  4. Open the page in the frontend — you should see your list view.

If the list is empty, check the storagePid first. See storagePid — when findAll() returns nothing.

What next? 

You have a working extension. From here: