Store uploaded files directly in the TYPO3 database.
A tiny, injectable service that persists files into a single DB table (with a
LONGBLOB content column) and hands them back out as PSR-7 responses. Works
from Extbase controllers, PSR-15 middlewares, CLI commands, or anything the
DI container builds.
- TYPO3: v13 LTS / v14
- PHP: 8.2+
- Composer:
b13/db-file-storage
composer require b13/db-file-storage
vendor/bin/typo3 extension:setupThe main entry point. Inject via constructor:
public function store(UploadedFileInterface $file): StoredFile;
public function storeContents(string $filename, string $contents, ?string $mimeType = null): StoredFile;
public function get(int $uid): ?StoredFile;
public function require(int $uid): StoredFile; // throws FileNotFoundException
public function delete(int $uid): bool; // soft-delete (sets deleted=1)
public function createResponse(int $uid, bool $forceDownload = false): ResponseInterface;StoredFile is an immutable value object (uid, filename, mimeType,
size, sha1, contents, createdAt). Every method after store*() takes
the uid returned by it.
A metadata-only Extbase entity for the same table, suitable for
ObjectStorage<StoredFileReference> properties and MM relations. Does not
map the content LONGBLOB — bytes stay behind DatabaseFileStorage::get().
Ships with a repository (StoredFileReferenceRepository), a minimal
hideTable / readOnly TCA stub, and a Persistence/Classes.php mapping
that's auto-merged into every consumer extension.
use B13\DbFileStorage\Service\DatabaseFileStorage;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
final class InvoiceController extends ActionController
{
public function __construct(
private readonly DatabaseFileStorage $databaseFileStorage,
) {}
public function uploadAction(): ResponseInterface
{
$uploadedFile = $this->request->getUploadedFiles()['invoice'] ?? null;
if ($uploadedFile === null) {
return (new \TYPO3\CMS\Core\Http\Response())->withStatus(400);
}
$stored = $this->databaseFileStorage->store($uploadedFile);
return $this->redirect('show', null, null, ['file' => $stored->uid]);
}
public function downloadAction(int $file): ResponseInterface
{
return $this->databaseFileStorage->createResponse($file, forceDownload: true);
}
}$stored = $this->databaseFileStorage->storeContents(
filename: 'invoice-' . $invoice->getNumber() . '.pdf',
contents: $pdfBytes,
mimeType: 'application/pdf',
);
$invoice->setFileUid($stored->uid);Add a group + MM column in your consumer TCA:
'attachments' => [
'label' => 'Attachments',
'config' => [
'type' => 'group',
'relationship' => 'manyToMany',
'allowed' => 'tx_dbfilestorage_domain_model_file',
'foreign_table' => 'tx_dbfilestorage_domain_model_file',
'MM' => 'tx_myext_task_file_mm',
],
],The MM table is auto-created by TYPO3's schema analyzer. On the Extbase side,
use StoredFileReference in your model:
use B13\DbFileStorage\Domain\Model\StoredFileReference;
use B13\DbFileStorage\Domain\Repository\StoredFileReferenceRepository;
// In your model:
/** @param ObjectStorage<StoredFileReference> $attachments */
protected ObjectStorage $attachments;
// In your upload action:
$stored = $this->databaseFileStorage->store($uploadedFile);
$ref = $this->storedFileReferenceRepository->findByUid($stored->uid);
$task->addAttachment($ref);
$this->taskRepository->update($task);Extbase writes the MM rows automatically on persistAll().
Note on cascade delete: Extbase's
#[Cascade('remove')]only works for 1:n relations (HAS_MANY), not for M:N (HAS_AND_BELONGS_TO_MANY). To soft-delete the file row when removing a relation, call$databaseFileStorage->delete($ref->getUid())explicitly in your controller.
One table — ext_tables.sql:
| Column | Type | Notes |
|---|---|---|
uid |
int auto_increment | Primary key |
deleted |
tinyint | Soft-delete flag |
filename |
varchar(255) | Original client filename |
mime_type |
varchar(127) | Detected via finfo / TYPO3 map |
size |
bigint | Size in bytes |
sha1 |
varchar(40) | Content hash, indexed |
content |
longblob | The file bytes |
crdate |
int | Creation timestamp |
MariaDB/MySQL's max_allowed_packet governs the maximum insert size.
ddev start # installs TYPO3, creates tables, ready to use- Backend: https://db-file-storage.ddev.site/typo3 —
admin/Password.1 - Tests:
ddev exec --dir /var/www/html/Build \ typo3DatabaseDriver=pdo_sqlite \ vendor/bin/phpunit -c ../Build/phpunit/FunctionalTests.xml
This extension was created by Jochen Roth in 2025 for b13 GmbH, Stuttgart.