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
File properties from a domain
model, and writing newly uploaded files using the
#
attribute.
On this page
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:
<?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;
}
}
The TCA definition:
<?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',
],
],
],
];
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:
<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>
Writing uploaded files with
#[FileUpload]
The
\TYPO3\ 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
File record. This is all
carried out automatically after property mapping succeeds.
Here is a domain model with
# on both properties:
<?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;
}
}
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/ and use
f:form.upload for each file
property:
<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>
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:
<?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');
}
}
Important
The model must be persisted in the same request that processes
the upload (via
$conference or
$conference). Without persistence, dangling
sys_ records are
created without a corresponding
File, leaving stale
temporary files that require manual cleanup.
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.
Note
File upload handling for nested domain models — for example
conference. — is not supported. The
# attribute must be placed on model properties
of the direct argument of the action.
Configuring the
#[FileUpload] attribute
The
# 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:/. The folder is created automatically unlessuser_ upload/ conference_ logos/' createis set toUpload Folder If Not Exist false. createUpload Folder If Not Exist bool, defaulttrue.addRandom Suffix bool, defaulttrue. 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\(default),CMS\ Core\ Resource\ Enum\ Duplication Behavior:: RENAME \TYPO3\, orCMS\ Core\ Resource\ Enum\ Duplication Behavior:: REPLACE \TYPO3\.CMS\ Core\ Resource\ Enum\ Duplication Behavior:: CANCEL
File upload validation
Two validators are enforced automatically for every
#
property and cannot be removed:
- FileName — rejects files with names
matching dangerous executable extensions such as
.phpor.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' => ['minimum' => '10K', 'maximum' => '2M'],
mimeType — restricts accepted content types by inspecting the file
content as well as the file name. Always combine with
file to cover
both the client-supplied extension and the server-detected type:
'mimeType' => ['allowedMimeTypes' => ['image/jpeg', 'image/png']],
fileExtension — validates the file extension as supplied by the client.
The allowedFileExtensions list should match allowedMimeTypes exactly:
'fileExtension' => ['allowedFileExtensions' => ['jpg', 'jpeg', 'png']],
imageDimensions — for image uploads, caps width and height to prevent oversized images from reaching the server's image processing pipeline:
'imageDimensions' => ['maxWidth' => 4096, 'maxHeight' => 4096],
The remaining keys — required, minFiles, maxFiles — control
upload count. For the full option reference see
File upload validators.
Warning
Keep the allowed MIME types and file extensions configured in
# in sync with the TCA type=file 'allowed' key on the same column. If they diverge, a file accepted
by the frontend form may be rejected by the TYPO3 backend, or a file
uploaded through the backend may not match the frontend validation rules.
Either mismatch leads to confusing behaviour that is hard to debug. Define
the allowed types once, in both places.
Also set the same list on
<f: so the
browser's file picker pre-filters the selectable types for a better user
experience — though this is a client-side hint only and not a security
control.
Manual file upload configuration
When
# is not flexible enough — for example if you want to add a custom
validator class — create a
\TYPO3\ object
manually in an
initialize*Action:
<?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');
}
}
Note the
skip call: Extbase's property mapping must not
operate on the file property when manual upload configuration is used.
The
# attribute handles this internally; with manual
configuration it must be done explicitly.
To modify configuration that is already defined by
# — for example
to read the upload folder out of the TypoScript settings — retrieve the existing
configuration object instead of creating a new one:
public function initializeCreateAction(): void
{
$argument = $this->arguments->getArgument('conference');
$configuration = $argument
->getFileHandlingServiceConfiguration()
->getFileUploadConfigurationForProperty('logo');
$configuration?->setUploadFolder(
$this->settings['logoUploadFolder'] ?? '1:/user_upload/conference_logos/',
);
}
To strip all application-level validators and start from a clean slate, call
$configuration->reset. The two mandatory validators
(FileName and
FileExtensionMimeTypeConsistency) are always
re-added and cannot be removed. Calling
reset 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_ record and the underlying file before
persisting the updated model:
<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>
Warning
Extbase deletes the file from
FAL without checking whether any other
record references the same file. This is different from the TYPO3 backend
which prevents the deletion of files that still have consumers. If the same
File is referenced by
multiple domain objects, deleting it via this mechanism will break the
other references silently. Ensure files are not shared before enabling
deletion.
Modifying the target filename before persistence
Modify
allows event listeners to change the filename of an uploaded file before it is
written to FAL. Use
get
to read the current name and
set to rename it. The
active
File is available via
get.
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
File) in the first step. Pass itsReference uidas a hidden field through subsequent steps. In the final step, attach theFileto the domain model.Reference - Client-side preview — use the JavaScript
FileAPI to render a preview of the selected file in the browser before the form is submitted, without a server round-trip.Reader ()
See also
- File upload validators — full reference for all file upload validators and their options.
- Using FAL — the File Abstraction Layer in TYPO3.