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. The extension does not need an ext_emconf.php file. TYPO3 and the TYPO3 Extension Repository read the metadata of the extension from composer.json.

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 getConferenceDate(): ?\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 initialize 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;

use TYPO3\CMS\Extbase\Persistence\Repository;

class ConferenceRepository extends 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',
  'my_extension.messages:plugin.conferencelist.title',
  'content-plugin',
);
Copied!

The icon argument passed to registerPlugin() must be a registered icon identifier, not a raw EXT: path — the example above uses TYPO3's built-in default plugin icon (content-plugin).

Registering a custom icon is optional. If you want one, register it in Configuration/Icons.php:

EXT:my_extension/Configuration/Icons.php
<?php

declare(strict_types=1);

use TYPO3\CMS\Core\Imaging\IconProvider\SvgIconProvider;

return [
  'my-extension-conference-list' => [
    'provider' => SvgIconProvider::class,
    'source' => 'EXT:my_extension/Resources/Public/Icons/Extension.svg',
  ],
];
Copied!

and reference its identifier (my-extension-conference-list) instead of content-plugin in the registerPlugin() call above.

Step 8: configure routing (optional but recommended) 

Without a route enhancer, URLs contain the raw plugin namespace parameters (for example: ?tx_myextension_conferencelist[action]=show&tx_myextension_conferencelist[conference]=5). Add an Extbase route enhancer to your site configuration to get clean URLs like /conferences/my-conference.

config/sites/my-site/config.yaml (excerpt)
routeEnhancers:
  ConferenceList:
    type: Extbase
    extension: MyExtension
    plugin: ConferenceList
    defaultController: 'Conference::list'
    routes:
      - routePath: '/conferences'
        _controller: 'Conference::list'
      - routePath: '/conferences/{conference}'
        _controller: 'Conference::show'
    aspects:
      conference:
        type: PersistedAliasMapper
        tableName: tx_myextension_domain_model_conference
        routeFieldName: slug
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 The storagePid constraint on repository queries.

What next? 

You have a working extension. From here: