Skip to content

Storing files

Store an uploaded file

Validate the HTTP boundary first:

php
use Illuminate\Http\Request;
use Mattmy\FileMagic\Facades\FileMagic;

public function store(Request $request)
{
    $validated = $request->validate([
        'document' => ['required', 'file', 'max:10240'],
    ]);

    $file = FileMagic::fromUpload($validated['document'])->store();

    return response()->json($file);
}

FileMagic inspects the content again. Request validation and package inspection protect different boundaries.

Store from other sources

Readable local path:

php
$file = FileMagic::fromPath(storage_path('imports/report.pdf'))
    ->inDirectory('reports')
    ->store();

Only pass paths chosen by trusted application code.

String or binary content:

php
$file = FileMagic::fromContent(
    contents: $pdfContents,
    originalFilename: 'invoice.pdf',
    mimeType: 'application/pdf',
)->inDirectory('invoices')->store();

The MIME argument is only a source hint. Stored MIME and extension come from content inspection.

Application-generated content whose format is guaranteed by the application can use a trusted MIME type:

php
$file = FileMagic::fromGeneratedContent(
    contents: $dxfContents,
    originalFilename: 'drawing.dxf',
    mimeType: 'image/vnd.dxf',
)->named('drawing')->store();

When Symfony cannot recognize a non-null MIME type, FileMagic delegates to fromContent(), and finfo determines the MIME type from the content. Omitting MIME also uses ordinary content inspection. For bytes not generated by the application, use the ordinary source entry point:

php
// Unsafe: request bytes and their MIME header are both untrusted.
FileMagic::fromGeneratedContent($request->getContent(), null, $request->header('Content-Type'));

The complete $contents string is already held in PHP memory. Use upload, path, or remote sources when the generated content may approach the worker's memory limit.

Plain Base64:

php
$file = FileMagic::fromBase64(
    base64: \base64_encode($contents),
    originalFilename: 'document.pdf',
)->store();

The Data URI prefix is optional. When omitted, FileMagic detects the MIME type from the decoded content instead of relying on caller-provided metadata.

Data URI:

php
$file = FileMagic::fromBase64(
    base64: 'data:text/plain;base64,'.\base64_encode('Hello'),
    originalFilename: 'hello.txt',
)->store();

Decoding is strict. Invalid or non-canonical input throws InvalidBase64, while oversized input throws FileTooLarge from its decoded size before decoding starts. Valid input is decoded in bounded chunks into a temporary stream: the encoded input remains in memory, while decoded bytes use temporary disk space. Prefer uploads or paths when the encoded input itself is large.

Customize storage

php
use Mattmy\FileMagic\Enums\CollisionPolicy;
use Mattmy\FileMagic\Enums\FileVisibility;

$file = FileMagic::fromUpload($uploadedFile)
    ->onDisk('s3')
    ->inDirectory('accounts/42/contracts')
    ->named('signed-contract')
    ->visibility(FileVisibility::Private)
    ->onCollision(CollisionPolicy::Unique)
    ->store();

named() takes a name without extension. Directories use canonical forward-slash-separated relative paths; an empty directory means the disk root. Leading or trailing separators, backslashes, repeated separators, whitespace around segments, control or Windows-unsafe characters, . and .., and reserved Windows names are rejected rather than normalized. Filenames follow the same character and reserved-name rules and cannot start or end with a dot.

Collision policies:

  • Unique adds a random suffix when the path exists.
  • Error throws FileWriteFailed.
  • Overwrite intentionally replaces the physical path and updates its existing database record.

Overwrite keeps the existing file available if replacement fails. It needs local temporary space close to the existing file size and performs more storage and disk work, so it is slower than normal storage. Prefer the default Unique policy unless the storage path must stay the same. If both replacement and recovery fail, FileRecoveryFailed is thrown.

Collision locking is disabled by default; this preserves compatibility but does not protect concurrent writers from TOCTOU races. When collision_lock.enabled is true, all collision policies make their existence decision while holding an atomic cache lock for the candidate disk and path. The lock remains held through the file write, database record, and any delete or restore compensation. Standard batch deletion and audit cleanup use the same path identity, so they cannot mutate that record while the store lock is held when all participants share the backend. If the lock cannot be acquired before collision_lock.wait_seconds, FileWriteFailed is thrown before the target is inspected or changed.

Size and MIME restrictions

php
$file = FileMagic::fromUpload($uploadedFile)
    ->maxSize(10 * 1024 * 1024)
    ->allowMimeTypes([
        'application/pdf',
        'image/jpeg',
        'image/png',
    ])
    ->blockMimeTypes([
        'image/svg+xml',
        'text/html',
    ])
    ->store();

Per-operation values override their corresponding global configuration. maxSize() applies to both the original input and the final image output. FileMagic uses finfo, not the browser-provided MIME header.

Metadata and ownership

php
$file = FileMagic::fromUpload($uploadedFile)
    ->withMetadata([
        'category' => 'invoice',
        'year' => 2026,
    ])
    ->ownedBy($user)
    ->store();

Metadata must be JSON-serializable and must not contain secrets. Any persisted Eloquent model can be an owner.

Add the inverse relation:

php
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Mattmy\FileMagic\Models\StoredFile;

public function files(): MorphMany
{
    return $this->morphMany(StoredFile::class, 'owner');
}

Eager-load the relation from the owning model, then pass an already loaded file model into FileMagic:

php
$post = Post::query()
    ->with('files')
    ->findOrFail($postId);

$attachment = $post->files->firstOrFail();

return FileMagic::find($attachment)->download();

Passing an existing StoredFile model to find() performs one scoped query to obtain the current canonical record. Eager loading still avoids a query for the owner relation itself.

The owner_id column is a string, so integer, UUID, and ULID owner keys are supported.

Last updated:

Released under the MIT License.