Skip to content

Usage ​

This guide covers the essential concepts and usage patterns for the FileStorage plugin.

Basic concepts ​

The file storage model ​

The plugin uses a single table (file_storage) to track all uploaded files in your application. Each record contains:

FieldDescription
idInteger database primary key.
uuidPublic/storage UUID used in signed URLs and path generation.
hashContent hash, SHA-256 by default.
blob_idShared blob reference when deduplication is enabled.
foreign_keyThe id of the entity this file belongs to (e.g. user id, post id). Type follows the global Polymorphic.type config (default integer): see Foreign key column types.
modelThe model name (e.g. Users, Posts).
collectionThe collection / type of file (e.g. Avatar, Cover, Gallery).
filenameOriginal filename.
filesizeSize in bytes.
mime_typeFile MIME type.
extensionFile extension.
pathStorage path.
adapterStorage adapter name (e.g. Local, S3).
variantsJSON array of image variant information (for images).
metadataJSON array for additional metadata.

Always load the table plugin prefixed

Use fetchTable('FileStorage.FileStorage'), never the bare fetchTable('FileStorage').

The unprefixed alias resolves in your application namespace. If you do not have an App\Model\Table\FileStorageTable, Cake hands you a generic Cake\ORM\Table that never ran the plugin's initialize(), so variants and metadata are typed as plain text: reads return the raw JSON string instead of an array, and writing an array fails with Cannot convert value Array of type array to string.

Assign arrays to variants and metadata, not JSON strings

Both columns are typed json, so Cake encodes them on write and decodes them on read. Passing an already encoded string (json_encode($variants)) stores it encoded twice, and the single decode on read then returns the JSON string rather than the array - getVariantUrl() and getVariantPath() silently return null for every variant. This matters for seeders, imports and migrations; the FileStorage behavior itself always assigns arrays.

Model vs collection ​

This distinction is important:

  • Model: the table alias (e.g. Users, Posts), from $this->table()->getAlias().
  • Collection: a grouping within a model (e.g. Avatar, Cover, Gallery).

For example, a Posts model might have a Cover collection for cover images, a Gallery collection for gallery images, and an Attachments collection for PDF attachments.

Setting up file storage ​

1. Install and load the plugin ​

See the Installation guide.

2. Configure the storage service ​

In your config/bootstrap.php or a dedicated config/storage.php loaded with require CONFIG . 'storage.php'; from bootstrap:

php
<?php
use Cake\Core\Configure;
use PhpCollective\Infrastructure\Storage\StorageAdapterFactory;
use PhpCollective\Infrastructure\Storage\StorageService;
use PhpCollective\Infrastructure\Storage\Factories\LocalFactory;
use PhpCollective\Infrastructure\Storage\FileStorage;
use PhpCollective\Infrastructure\Storage\PathBuilder\PathBuilder;
use PhpCollective\Infrastructure\Storage\Utility\FilenameSanitizer;

// Storage setup
$storageFactory = new StorageAdapterFactory();
$storageService = new StorageService($storageFactory);

// This example stores PUBLIC files under webroot.
// For private files use ROOT . DS . 'storage' . DS . 'files' instead.
// Add a Local storage adapter
$storageService->addAdapterConfig(
    'Local',
    LocalFactory::class,
    [
        'root' => WWW_ROOT . 'files' . DS,
    ],
);

// Configure the path builder
$pathBuilder = new PathBuilder([
    'pathTemplate' => '{model}{ds}{collection}{ds}{randomPath}{ds}{strippedId}{ds}{filename}.{extension}',
    'variantPathTemplate' => '{model}{ds}{collection}{ds}{randomPath}{ds}{strippedId}{ds}{filename}.{hashedVariant}.{extension}',
    'randomPathLevels' => 1,
    'filenameSanitizer' => new FilenameSanitizer([
        'urlSafe' => true,
        'removeUriReservedChars' => true,
        'maxLength' => 190,
    ]),
]);

// Create the file storage instance
$fileStorage = new FileStorage($storageService, $pathBuilder);

// Store in configuration for behavior usage
Configure::write('FileStorage.behaviorConfig', [
    'fileStorage' => $fileStorage,
    'fileProcessor' => null, // add an image processor if needed
    'fileValidator' => null, // add a custom validator if needed
]);

3. Use the right behavior for the right table ​

The FileStorage.FileStorage behavior processes the upload entity itself. It is attached to the plugin's FileStorage.FileStorage table automatically.

For uploads saved through an app table association, attach FileStorage.FileAssociation to your app table instead. This behavior fills the model, collection, and foreign_key fields on the associated file entity and then lets the plugin table process the upload.

php
$this->addBehavior('FileStorage.FileAssociation', [
    'associations' => [
        'CoverImages' => [
            'collection' => 'Cover',
            'replace' => true,
        ],
    ],
]);

Do not add FileStorage.FileStorage directly to your UsersTable, PostsTable, or other app tables for associated uploads. It expects a top-level upload field on the entity it is saving, so it can stop the parent entity save before CakePHP gets to the associated file entity.

The fileField option ​

By default the upload entity behavior looks for a 'file' field in the uploaded file data, so associated form fields should be named *.file:

php
// Default: fileField => 'file'
echo $this->Form->control('avatar.file', ['type' => 'file']);

To use a different field name, configure the plugin table behavior through FileStorage.behaviorConfig:

php
Configure::write('FileStorage.behaviorConfig', [
    // ...
    'fileField' => 'upload', // custom field name
]);
php
echo $this->Form->control('avatar.upload', ['type' => 'file']);

This applies to direct saves on the file table. FileAssociation currently expects file, so keep that name when saving through parent associations.

Adding file upload to your model ​

Create the association ​

In your table class (e.g. PostsTable.php):

php
public function initialize(array $config): void
{
    parent::initialize($config);

    // Single file association (hasOne)
    $this->hasOne('CoverImages', [
        'className' => 'FileStorage.FileStorage',
        'foreignKey' => 'foreign_key',
        'conditions' => [
            'CoverImages.model' => 'Posts',
            'CoverImages.collection' => 'Cover',
        ],
        'dependent' => true,
        'cascadeCallbacks' => true,
    ]);

    // Multiple files association (hasMany)
    $this->hasMany('GalleryImages', [
        'className' => 'FileStorage.FileStorage',
        'foreignKey' => 'foreign_key',
        'conditions' => [
            'GalleryImages.model' => 'Posts',
            'GalleryImages.collection' => 'Gallery',
        ],
        'dependent' => true,
        'cascadeCallbacks' => true,
    ]);

    $this->addBehavior('FileStorage.FileAssociation', [
        'associations' => [
            'CoverImages' => [
                'collection' => 'Cover',
                'replace' => true,
            ],
            'GalleryImages' => [
                'collection' => 'Gallery',
            ],
        ],
    ]);
}

Make the entity fields accessible ​

In your entity (e.g. Post.php):

php
protected array $_accessible = [
    'title' => true,
    'body' => true,
    'cover_image' => true,   // hasOne association (singular property)
    'gallery_images' => true, // hasMany association (plural property)
    // … other fields
];

Uploading files ​

Form template ​

php
// Single file upload (hasOne)
<?= $this->Form->create($post, ['type' => 'file']) ?>
<?= $this->Form->control('title') ?>
<?= $this->Form->control('cover_image.file', ['type' => 'file', 'label' => 'Cover Image']) ?>
<?= $this->Form->button(__('Submit')) ?>
<?= $this->Form->end() ?>

// Multiple file uploads (hasMany)
<?= $this->Form->create($post, ['type' => 'file']) ?>
<?= $this->Form->control('title') ?>
<?= $this->Form->control('gallery_images.0.file', ['type' => 'file', 'label' => 'Gallery Image 1']) ?>
<?= $this->Form->control('gallery_images.1.file', ['type' => 'file', 'label' => 'Gallery Image 2']) ?>
<?= $this->Form->button(__('Submit')) ?>
<?= $this->Form->end() ?>

Field naming

  • Associated uploads through FileAssociation use *.file. A custom fileField on the upload behavior alone does not change the association behavior.
  • For hasOne, use the singular property name (e.g. cover_image.file).
  • For hasMany, use the plural property name with an index (e.g. gallery_images.0.file).

Controller action ​

php
public function add()
{
    $post = $this->Posts->newEmptyEntity();

    if ($this->request->is('post')) {
        $post = $this->Posts->patchEntity($post, $this->request->getData(), [
            'fields' => ['title', 'body', 'cover_image', 'gallery_images'],
            'associated' => [
                'CoverImages' => ['fields' => ['file', 'filename', 'filesize', 'mime_type', 'extension']],
                'GalleryImages' => ['fields' => ['file', 'filename', 'filesize', 'mime_type', 'extension']],
            ],
        ]);

        if ($this->Posts->save($post)) {
            $this->Flash->success(__('The post has been saved.'));

            return $this->redirect(['action' => 'index']);
        }
        $this->Flash->error(__('The post could not be saved.'));
    }

    $this->set(compact('post'));
}

Retrieving and displaying files ​

Load files with the entity ​

php
$post = $this->Posts->get($id, contain: ['CoverImages', 'GalleryImages']);

Display in a template ​

php
// hasOne: singular property
<?php if ($post->cover_image) { ?>
    <img src="/files/<?= h($post->cover_image->path) ?>" alt="Cover">
<?php } ?>

// hasMany: plural property
<?php foreach ($post->gallery_images as $image) { ?>
    <img src="/files/<?= h($image->path) ?>" alt="Gallery Image">
<?php } ?>

These direct URLs work only for the public local root above. For private storage, generate an authorized signed URL. The Image helper does not authorize requests; for this public root set FileStorage.pathPrefix to files/.

For displaying image variants, prefer the Image helper: it handles variant lookup, fallbacks, and modern formats for you.

Deleting files ​

Files are automatically deleted when you delete the entity, thanks to the behavior's afterDelete callback:

php
// Deletes both the database record and the physical file
$this->Posts->delete($post);

Delete a single file storage record ​

php
$this->fetchTable('FileStorage.FileStorage')->delete($coverImage);

Bulk delete ​

Never use deleteAll()

deleteAll() does not trigger callbacks, so the physical files are left behind on disk. Use the behavior helper instead.

php
// Wrong: files won't be deleted from storage
$this->fetchTable('FileStorage.FileStorage')->deleteAll(['model' => 'Posts']);

// Right: use the behavior's helper method
$this->fetchTable('FileStorage.FileStorage')
    ->getBehavior('FileStorage')->deleteAllFiles(['model' => 'Posts']);

Deduplicated files ​

A row in a deduplicated collection shares its stored file with other rows. Deleting it removes the row and its variants. The shared file stays, also when this was the last row using it, until bin/cake file_storage cleanup removes it after the grace period.

Custom storage adapters ​

You can use storage backends beyond the local filesystem.

Amazon S3 example ​

Install the adapter first:

bash
composer require league/flysystem-aws-s3-v3

Use the AWS SDK credential provider chain (environment or instance role):

php
use PhpCollective\Infrastructure\Storage\Factories\AwsS3v3Factory;

$storageService->addAdapterConfig(
    'S3',
    AwsS3v3Factory::class,
    [
        'bucket' => 'your-bucket-name',
        'client' => [
            'region' => 'us-east-1',
            'version' => 'latest',
        ],
    ],
);

Choose the adapter in trusted server code after marshalling; do not accept adapter, path, ownership or other storage fields from submitted form data:

php
$post->cover_image->adapter = 'S3'; // hasOne: singular property

See also ​

Released under the MIT License.