File uploads in Extbase domain models 

Extbase can handle file uploads as part of its normal property mapping and persistence flow by using the FAL (File Abstraction Layer) for storage. Two distinct topics are covered here: reading existing FileReference properties from a domain model, and writing newly uploaded files using the #[FileUpload] attribute.

Reading a file reference from a domain model 

Reading an existing file reference requires two components: the model property and the corresponding TCA column definition.

The domain model:

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

declare(strict_types=1);

namespace MyVendor\MyExtension\Domain\Model;

use TYPO3\CMS\Extbase\Domain\Model\FileReference;
use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;
use TYPO3\CMS\Extbase\Persistence\ObjectStorage;

class Conference extends AbstractEntity
{
    protected ?FileReference $logo = null;

    /**
     * @var ObjectStorage<FileReference>
     */
    protected ObjectStorage $impressions;

    public function __construct()
    {
        $this->impressions = new ObjectStorage();
    }

    public function initializeObject(): void
    {
        $this->impressions = $this->impressions ?? new ObjectStorage();
    }

    public function getLogo(): ?FileReference
    {
        return $this->logo;
    }

    public function setLogo(?FileReference $logo): void
    {
        $this->logo = $logo;
    }

    /**
     * @return ObjectStorage<FileReference>
     */
    public function getImpressions(): ObjectStorage
    {
        return $this->impressions;
    }

    public function setImpressions(ObjectStorage $impressions): void
    {
        $this->impressions = $impressions;
    }
}
Copied!

The TCA definition:

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

return [
    'ctrl' => [
        // ... table-specific ctrl fields
    ],
    'columns' => [
        // ... table-specific columns
        'logo' => [
            'exclude' => true,
            'label' => 'Logo',
            'config' => [
                'type' => 'file',
                'maxitems' => 1,
                'allowed' => 'common-image-types',
            ],
        ],
        'impressions' => [
            'exclude' => true,
            'label' => 'Impressions',
            'config' => [
                'type' => 'file',
                'allowed' => 'common-image-types',
            ],
        ],
    ],
];
Copied!

Once a record has files attached via the TYPO3 backend, the controller retrieves them through the standard Extbase getters and the Fluid template renders them:

EXT:my_extension/Resources/Private/Templates/Conference/Show.fluid.html
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
      data-namespace-typo3-fluid="true">

<f:layout name="Default" />

<f:section name="main">
    <f:if condition="{conference.logo}">
        <f:image image="{conference.logo}" cropVariant="default" alt="{conference.title}" />
    </f:if>

    <f:for each="{conference.impressions}" as="impression">
        <f:image image="{impression}" cropVariant="default" alt="{conference.title}" />
    </f:for>
</f:section>

</html>
Copied!

Writing uploaded files with #[FileUpload] 

The \TYPO3\CMS\Extbase\Attribute\FileUpload attribute on a model property is all that is needed for upload processing. Extbase validates the upload, moves the file into the configured FAL storage folder, and creates the FileReference record. This is all carried out automatically after property mapping succeeds.

Here is a domain model with #[FileUpload] on both properties:

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

declare(strict_types=1);

namespace MyVendor\MyExtension\Domain\Model;

use TYPO3\CMS\Extbase\Attribute\FileUpload;
use TYPO3\CMS\Extbase\Domain\Model\FileReference;
use TYPO3\CMS\Extbase\DomainObject\AbstractEntity;
use TYPO3\CMS\Extbase\Persistence\ObjectStorage;

class Conference extends AbstractEntity
{
    #[FileUpload(
        validation: [
            'required' => false,
            'maxFiles' => 1,
            'fileSize' => ['minimum' => '10K', 'maximum' => '2M'],
            'mimeType' => ['allowedMimeTypes' => ['image/jpeg', 'image/png']],
            'fileExtension' => ['allowedFileExtensions' => ['jpg', 'jpeg', 'png']],
        ],
        uploadFolder: '1:/user_upload/conference_logos/',
    )]
    protected ?FileReference $logo = null;

    /**
     * @var ObjectStorage<FileReference>
     */
    #[FileUpload(
        validation: [
            'fileSize' => ['minimum' => '10K', 'maximum' => '10M'],
            'mimeType' => ['allowedMimeTypes' => ['image/jpeg', 'image/png']],
            'fileExtension' => ['allowedFileExtensions' => ['jpg', 'jpeg', 'png']],
        ],
        uploadFolder: '1:/user_upload/conference_impressions/',
    )]
    protected ObjectStorage $impressions;

    public function __construct()
    {
        $this->impressions = new ObjectStorage();
    }

    public function initializeObject(): void
    {
        $this->impressions = $this->impressions ?? new ObjectStorage();
    }

    public function getLogo(): ?FileReference
    {
        return $this->logo;
    }

    public function setLogo(?FileReference $logo): void
    {
        $this->logo = $logo;
    }

    /**
     * @return ObjectStorage<FileReference>
     */
    public function getImpressions(): ObjectStorage
    {
        return $this->impressions;
    }

    public function setImpressions(ObjectStorage $impressions): void
    {
        $this->impressions = $impressions;
    }
}
Copied!

The TCA definition is identical to the read-only case above. No special TCA configuration is required for upload handling.

The upload form must declare enctype="multipart/form-data" and use f:form.upload for each file property:

EXT:my_extension/Resources/Private/Templates/Conference/New.fluid.html
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
      data-namespace-typo3-fluid="true">

<f:layout name="Default" />

<f:section name="main">
    <f:form action="create" name="conference" object="{conference}" enctype="multipart/form-data">
        <div>
            <label>Logo</label>
            <f:form.upload property="logo" />
        </div>

        <div>
            <label>Impressions</label>
            <f:form.upload property="impressions" multiple="1" />
        </div>

        <f:form.submit value="Save" />
    </f:form>
</f:section>

</html>
Copied!

The controller actions do not require any special upload code. Extbase handles everything that happens between property mapping and persistence. The only requirement is that the model is persisted within the same request:

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 newAction(): ResponseInterface
    {
        $this->view->assign('conference', new Conference());
        return $this->htmlResponse();
    }

    public function createAction(Conference $conference): ResponseInterface
    {
        $this->conferenceRepository->add($conference);
        return $this->redirect('list');
    }

    public function editAction(Conference $conference): ResponseInterface
    {
        $this->view->assign('conference', $conference);
        return $this->htmlResponse();
    }

    public function updateAction(Conference $conference): ResponseInterface
    {
        $this->conferenceRepository->update($conference);
        return $this->redirect('list');
    }
}
Copied!

Upload processing runs after property mapping completes. If a model property fails validation, the file is not uploaded and the user must re-upload on the next attempt. This avoids stale temporary files.

Configuring the #[FileUpload] attribute 

The #[FileUpload] attribute accepts named arguments as follows:

validation
Array configuring built-in file upload validators. See File upload validation below.
uploadFolder
Destination as a FAL storage path, for example '1:/user_upload/conference_logos/'. The folder is created automatically unless createUploadFolderIfNotExist is set to false.
createUploadFolderIfNotExist
bool, default true.
addRandomSuffix
bool, default true. Appends a random 16-character hash to the persisted filename to prevent enumeration. Recommended to keep enabled.
duplicationBehavior
FAL behaviour when a file with the same name already exists in the target folder: \TYPO3\CMS\Core\Resource\Enum\DuplicationBehavior::RENAME (default), \TYPO3\CMS\Core\Resource\Enum\DuplicationBehavior::REPLACE, or \TYPO3\CMS\Core\Resource\Enum\DuplicationBehavior::CANCEL.

File upload validation 

Two validators are enforced automatically for every #[FileUpload] property and cannot be removed:

  • FileName — rejects files with names matching dangerous executable extensions such as .php or .phar. This is a hard security boundary. There is no legitimate reason to bypass it.
  • FileExtensionMimeTypeConsistency — cross-checks that the file extension and the detected MIME type are consistent, guarding against disguised uploads such as a PHP script renamed to image.jpg.

Beyond these two validators, the validation array configures additional validators via shorthand keys. The most important ones for a public upload form are:

fileSize — always set a meaningful lower and upper bound. A minimum of 10K rejects empty or near-empty files that would otherwise pass silently. An upper bound prevents storage exhaustion:

fileSize example
'fileSize' => ['minimum' => '10K', 'maximum' => '2M'],
Copied!

mimeType — restricts accepted content types by inspecting the file content as well as the file name. Always combine with fileExtension to cover both the client-supplied extension and the server-detected type:

mimeType example
'mimeType' => ['allowedMimeTypes' => ['image/jpeg', 'image/png']],
Copied!

fileExtension — validates the file extension as supplied by the client. The allowedFileExtensions list should match allowedMimeTypes exactly:

fileExtension example
'fileExtension' => ['allowedFileExtensions' => ['jpg', 'jpeg', 'png']],
Copied!

imageDimensions — for image uploads, caps width and height to prevent oversized images from reaching the server's image processing pipeline:

imageDimensions example
'imageDimensions' => ['maxWidth' => 4096, 'maxHeight' => 4096],
Copied!

The remaining keys — required, minFiles, maxFiles — control upload count. For the full option reference see File upload validators.

Manual file upload configuration 

When #[FileUpload] is not flexible enough — for example if you want to add a custom validator class — create a \TYPO3\CMS\Extbase\Mvc\Controller\FileUploadConfiguration object manually in an initialize*Action():

EXT:my_extension/Classes/Controller/ConferenceController.php (excerpt)
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Controller;

use MyVendor\MyExtension\Domain\Repository\ConferenceRepository;
use MyVendor\MyExtension\Validation\Validator\LogoDimensionsValidator;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
use TYPO3\CMS\Extbase\Mvc\Controller\FileUploadConfiguration;

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

    public function initializeCreateAction(): void
    {
        $logoValidator = GeneralUtility::makeInstance(LogoDimensionsValidator::class);

        $fileHandlingServiceConfiguration = $this->arguments
            ->getArgument('conference')
            ->getFileHandlingServiceConfiguration();

        $fileHandlingServiceConfiguration->addFileUploadConfiguration(
            (new FileUploadConfiguration('logo'))
                ->setMaxFiles(1)
                ->addValidator($logoValidator)
                ->setUploadFolder('1:/user_upload/conference_logos/'),
        );

        $this->arguments->getArgument('conference')
            ->getPropertyMappingConfiguration()
            ->skipProperties('logo');
    }
}
Copied!

Note the skipProperties() call: Extbase's property mapping must not operate on the file property when manual upload configuration is used. The #[FileUpload] attribute handles this internally; with manual configuration it must be done explicitly.

To modify configuration that is already defined by #[FileUpload] — for example to read the upload folder out of the TypoScript settings — retrieve the existing configuration object instead of creating a new one:

EXT:my_extension/Classes/Controller/ConferenceController.php
public function initializeCreateAction(): void
{
    $argument = $this->arguments->getArgument('conference');
    $configuration = $argument
        ->getFileHandlingServiceConfiguration()
        ->getFileUploadConfigurationForProperty('logo');
    $configuration?->setUploadFolder(
        $this->settings['logoUploadFolder'] ?? '1:/user_upload/conference_logos/',
    );
}
Copied!

To strip all application-level validators and start from a clean slate, call $configuration->resetValidators(). The two mandatory validators (FileName and FileExtensionMimeTypeConsistency) are always re-added and cannot be removed. Calling resetValidators() on a public-facing upload form without immediately adding back type and size restrictions will leave the upload open to any file content. Do this only when you have a deliberate, application-specific reason and compensate with other controls.

Deleting uploaded files 

The f:form.uploadDeleteCheckbox ViewHelper renders a Delete file checkbox. When submitted, Extbase deletes the sys_file_reference record and the underlying file before persisting the updated model:

EXT:my_extension/Resources/Private/Templates/Conference/Edit.fluid.html
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers"
      data-namespace-typo3-fluid="true">

<f:layout name="Default" />

<f:section name="main">
    <f:form action="update" name="conference" object="{conference}" enctype="multipart/form-data">
        <div>
            <label>Logo</label>
            <f:if condition="{conference.logo}">
                <f:form.uploadDeleteCheckbox id="deleteLogo"
                                             property="logo"
                                             fileReference="{conference.logo}" />
                <label for="deleteLogo">Delete logo</label>
            </f:if>
            <f:form.upload property="logo" />
        </div>

        <div>
            <label>Impressions</label>
            <f:for each="{conference.impressions}" as="impression" iteration="i">
                <f:form.uploadDeleteCheckbox property="impressions.{i.index}"
                                             fileReference="{impression}" />
                <label>Delete impression</label>
            </f:for>
            <f:form.upload property="impressions" multiple="1" />
        </div>

        <f:form.submit value="Save" />
    </f:form>
</f:section>

</html>
Copied!

Modifying the target filename before persistence 

ModifyUploadedFileTargetFilenameEvent allows event listeners to change the filename of an uploaded file before it is written to FAL. Use getTargetFilename() to read the current name and setTargetFilename() to rename it. The active FileUploadConfiguration is available via getConfiguration().

File uploads in multi-step forms 

Extbase file upload handling is coupled to the persistence step of a single action. If a multi-step form must carry file state across requests before final persistence, two patterns will work:

  • DTO approach — persist the uploaded file as a standalone domain object (or a dedicated DTO with its own FileReference) in the first step. Pass its uid as a hidden field through subsequent steps. In the final step, attach the FileReference to the domain model.
  • Client-side preview — use the JavaScript FileReader() API to render a preview of the selected file in the browser before the form is submitted, without a server round-trip.