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
folderablemorph backs two unrelated features. TheFoldermodel organizes files in a tree, while the separateFolderablemodel 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.