← Laracrate 9 / 17

Folders and slots

Folders and slots

Folders

Folders give a model a tree of named folders to organize its files. They are purely logical: a folder never changes where the binary lives in your disk (a file's storage key stays the same when you move it between folders). Use folders when you want a drive-like UI on top of a fileable (a user's personal drive, an organization's shared drive, a per-case document tree).

Add the HasFolders trait to any model. It pairs with HasFiles but is independent: a model can use one, the other, or both.

use EduLazaro\Laracrate\Concerns\HasFiles;
use EduLazaro\Laracrate\Concerns\HasFolders;

class Organization extends Model
{
    use HasFiles;
    use HasFolders;
}

Creating folders

Call addFolder() on the model. Pass a parent Folder to nest, or leave it null to create a root folder. The folder's polymorphic folderable_* morph is set to the owning model automatically.

$contracts = $organization->addFolder('Contracts');
$y2025     = $organization->addFolder('2025', parent: $contracts);

// Optional audit and metadata:
$folder = $organization->addFolder(
    name: 'Invoices',
    parent: null,
    creator: auth()->user(),   // defaults to auth()->user() when null
    metadata: ['color' => '#a855f7'],
);

addFolder() throws InvalidArgumentException if the $parent you pass belongs to a different folderable. The full signature is:

public function addFolder(
    string $name,
    ?Folder $parent = null,
    ?Model $creator = null,
    array $metadata = []
): Folder

Listing the tree

Call Returns
$organization->folders() MorphMany of every folder at any depth
$organization->rootFolders() Only top-level folders (parent_id null), ordered by name
$folder->children() Direct child folders (one level), ordered by name
$folder->descendants() Query of all nested folders below, via the denormalized path (one indexed query, no SQL recursion)
$folder->files() Files directly in this folder (not recursive)
$folder->allFiles() Top-level files in this folder and all its descendants
$folder->breadcrumb() Array of ancestor folders from root to this one, for breadcrumbs
$folder->sizeBytes() Sum of size for every file in the subtree
foreach ($organization->rootFolders as $folder) {
    echo $folder->name . ' (' . $folder->sizeBytes() . ' bytes)';
}

Putting files into folders

When you create a file, pass the target folder to addFile() (see the Working with files from your models section):

$organization->addFile($upload, 'drive', folder: $contracts);

To move an existing file, call moveToFolder() on the File. Pass null to move it back to the fileable root.

$file->moveToFolder($y2025);   // into a folder
$file->moveToFolder(null);     // back to the root

Both addFile() and moveToFolder() throw InvalidArgumentException if the folder belongs to a different fileable, so a file can never be attached to another owner's folder.

Moving and renaming folders

Move a folder under a new parent with moveTo() (null moves it to root):

$y2025->moveTo($archive);

moveTo() refuses two things: moving a folder between different folderables, and any move that would create a cycle (making a folder a descendant of itself). Both raise InvalidArgumentException.

The denormalized path column (for example Contracts/2025) is the source of truth for fast listings, and parent_id is the source of truth for structure. The FolderObserver keeps them in sync: on every save it recomputes path from parent->path + name, and after an update it cascades the new path to all descendants. Renaming Contracts to Agreements rewrites Contracts/2025 to Agreements/2025 automatically. Setting path by hand is pointless because the observer overwrites it.

Deleting folders

Folder uses soft deletes. To remove a whole subtree permanently, call forceDeleteRecursive(). It force-deletes every file in the subtree first (which fires the FileObserver and purges the binaries plus chunks), then the descendant folders deepest-first, then the folder itself.

$contracts->forceDeleteRecursive();

The folderable morph backs two unrelated features. The Folder model organizes files in a tree, while the separate Folderable model is a per-collection usage counter. They share the morph name but nothing else. Usage tracking is covered in the Multi-tenancy, buckets and usage section.

File slots

A file slot is a structured "you must upload X" requirement: a named target with rules about what can land in it and how many files it accepts. Use slots for things like an admission checklist ("Upload your ID", "Upload proof of address") or a quota ("Upload up to 3 invoices for June"). A slot does not classify or categorize your files, it only defines where files fit and under what rules. Categories, tags, and hierarchies stay in your app.

Slots live in laracrate_file_slots and link to files through the laracrate_file_slot_pivot table (many-to-many, so one file can satisfy several slots).

Defining a slot

Create a FileSlot directly. Every rule is optional; an empty rule means "no restriction".

use EduLazaro\Laracrate\Models\FileSlot;

$slot = FileSlot::create([
    'name'                  => 'National ID',
    'description'           => 'Upload your ID document',
    'allowed_extensions'    => ['pdf', 'jpg', 'png'],
    'allowed_types'         => ['document', 'image'],
    'max_files_per_creator' => 1,
    'max_files_total'       => null,
    'tenant_type'           => $org->getMorphClass(),
    'tenant_id'             => $org->getKey(),
    'context_type'          => $case->getMorphClass(),
    'context_id'            => $case->getKey(),
]);
Column Type Purpose
name string Slot label shown to the uploader
description string, nullable Optional helper text
color string, nullable Optional UI color
allowed_extensions array, nullable Allowed file extensions (empty = any)
allowed_types array, nullable Allowed FileType values: document, image, video, audio (empty = any)
max_files_per_creator int, nullable Per-creator limit (null = unlimited)
max_files_total int, nullable Global limit across all creators (null = unlimited)
position int Display order, defaults to 0
tenant_type / tenant_id morph, nullable Multi-tenant scope, same convention as File
context_type / context_id morph, nullable Optional finer scope inside the tenant (for example one case)

Attaching files to slots

Pass the slots when you create the file. The $slots argument of addFile() accepts FileSlot models or their IDs, and they are validated before the file is written:

$organization->addFile($upload, 'documents', slots: [$slot]);

During creation Laracrate checks each slot's extension rule and quota. If the slot does not accept the file's extension, or the per-creator or global limit is already reached, addFile() throws InvalidArgumentException and no file is created. On success the file is attached with syncWithoutDetaching, so re-attaching is idempotent.

You can also manage the relation directly from either side:

$slot->files;    // BelongsToMany of files in this slot
$file->slots;    // BelongsToMany of slots this file satisfies

$file->slots()->syncWithoutDetaching([$slot->id]);

Checking rules and completion

FileSlot exposes the predicates used during upload, so you can drive UI and your own validation with the same logic.

Method Returns
uploadedCount(?string $creatorType = null, ?int $creatorId = null) Number of files in the slot, optionally filtered by creator morph
canAcceptMore(?string $creatorType = null, ?int $creatorId = null) Array ['can' => bool, 'reason' => 'global'|'per_creator'|null, 'limit' => int|null]
acceptsExtension(string $extension) Whether the extension is allowed (empty list = any)
acceptsType(string $type) Whether the FileType value is allowed (empty list = any)
accepts(File $file) Full check on a file: extension AND type rules must both pass when both are declared
// Is this required slot satisfied for the current user?
$done = $slot->uploadedCount($user->getMorphClass(), $user->getKey()) >= 1;

// Can the user add another file?
$check = $slot->canAcceptMore($user->getMorphClass(), $user->getKey());
if (! $check['can']) {
    // $check['reason'] is 'global' or 'per_creator', $check['limit'] is the cap
}

// Pre-flight a file before showing an upload button:
if ($slot->accepts($file)) {
    // extension and type both allowed
}

uploadedCount() and canAcceptMore() count across all creators when you omit the creator arguments, so passing no arguments gives you the global totals while passing the creator morph scopes to one uploader.