oi-lab/oi-laravel-drive gives any entity of the app (a Team, an Organization…) a drive: a tree of folders and files. It reuses the Folder/File/Attachment models of oi-laravel-attachments and adds the entity, an owner, a date of creation, a trash, a JSON API, MCP tools and events.
php artisan boost:add-skill oi-lab/oi-laravel-drive
Save this content to: .claude/skills/oilab-laravel-drive/SKILL.md
# OI Laravel Drive — AI Context
`oi-lab/oi-laravel-drive` gives any entity of the app (a Team, an Organization…) a drive: a tree of folders
and files. It reuses the `Folder`/`File`/`Attachment` models of oi-laravel-attachments and adds the entity,
an owner, a date of creation, a trash, a JSON API, MCP tools and events.
## Core Concepts
| Model | Table | Notes |
|-------|-------|-------|
| `OiLab\OiLaravelDrive\Models\Drive` | `drives` | `driveable` morph (the entity), `name`, `user_id` (owner), UUID route key |
| `OiLab\OiLaravelDrive\Models\DriveEntry` | `drive_entries` | One per folder/file: `drive_id`, `entry` morph, `kind`, `user_id` (owner), `created_at`, `trashed_with_folder_id`; managed items: `key` (unique per drive), `role` (`notice`), `source` morph (linked file), `locked`, `sealed` |
| attachments `Folder` | `folders` | Tree via `parent_id`; soft deletes = trash |
| attachments `File` | `files` | Stored on `files.disk` under `drive/{drive uuid}/`; soft deletes = trash |
| attachments `Attachment` | `attachments` | Collection `drive`: files a file in a folder (`attachable` = folder) |
| `OiLab\OiLaravelDrive\Models\DriveUpload` | `drive_uploads` | One-time upload link opened by an assistant |
| `OiLab\OiLaravelDrive\Models\DriveFolderShare` | `drive_folder_shares` | Folder shared read-only with another entity (`shareable` morph) |
| `OiLab\OiLaravelDrive\Models\DriveFolderLink` | `drive_folder_links` | Public link of a folder: `token`, hashed `password`, `expires_at`, `views_count` |
Runtime relations added to the attachments models (no change to their classes):
`$folder->driveEntry`, `$file->driveEntry`, `$file->driveFolders` (active folders only), `$folder->driveFiles`,
`$folder->driveShares`, `$folder->driveLink`.
Rules:
- A file filed in no active folder is at the **root**. One file may be in several folders.
- Deleting a file removes it from every folder. Taking it out of one folder is `detach`.
- Deleting a folder takes its subfolders; `withFiles` also takes the files filed **only** in that tree;
shared files are only taken out. Without `withFiles`, the files stay (at the root if filed nowhere else).
- With the trash on, deletions are soft; what is trashed with a folder (`trashed_with_folder_id`) comes back
with it. `force` or `trash.enabled = false` deletes for good (storage included).
- Public identifiers (API, MCP, Data `id`) are **UUIDs**.
- **Managed items** (generated by the host): a `locked` item cannot be renamed, moved or deleted, nor a
folder holding one; a `sealed` folder takes no folder or file and lets none out. Users get a 403
`DriveException` (`locked`, `locked_content`, `sealed`); the host changes its items inside
`DriveLocks::bypass(fn () => …)`.
- A **linked file** (`link()`) shows the stored object of another model: no copy, and deleting the link
(trash purge included) never deletes the object. A **notice** is a locked `Notice.md` explaining its
folder, given with its text in `DriveListingData::notice`.
## Public API
```php
use OiLab\OiLaravelDrive\Services\DriveManager;
use OiLab\OiLaravelDrive\Services\DriveFolders;
use OiLab\OiLaravelDrive\Services\DriveFiles;
use OiLab\OiLaravelDrive\Services\DrivePresenter;
$drives = app(DriveManager::class);
$drive = $drives->driveFor($team); // default drive, created on first use
$drive = $drives->resolveDrive($user, $uuid); // by UUID, or the user's current entity's default drive
$folder = app(DriveFolders::class)->create($drive, 'Clients', parent: null, owner: $user, createdAt: null);
app(DriveFolders::class)->update($folder, name: 'Clients 2026');
app(DriveFolders::class)->move($folder, $parentOrNull);
app(DriveFolders::class)->delete($folder, withFiles: false, force: false);
app(DriveFolders::class)->restore($folder);
$files = app(DriveFiles::class);
$file = $files->store($drive, $uploadedFile, folders: [$folder], owner: null, createdAt: null, title: null, description: null);
$file = $files->storeContent($drive, $content, 'notes.md', folders: []);
$files->update($file, ['name' => 'x.md', 'title' => '…', 'description' => '…']);
$files->attach($file, [$a, $b]); $files->detach($file, [$a]); $files->sync($file, []);
$files->delete($file); $files->restore($file);
$files->downloadUrl($file); // ['url' => …, 'expires_at' => …]
$files->text($file); // ['text' => …, 'truncated' => bool] or null (binary)
$drives->contents($drive, $folderOrNull); // DriveListingData
$drives->search($drive, 'term'); // DriveItemsData
$drives->trash($drive); $drives->emptyTrash($drive); $drives->purgeTrash($days);
$drives->setOwner($folderOrFile, $userOrNull); $drives->setCreatedAt($folderOrFile, $date);
$drives->findFolder($uuid, withTrashed: false); $drives->findFile($uuid); $drives->driveOf($item);
app(DrivePresenter::class)->file($file); // DriveFileData (folder_ids, owner, created_at…)
// Managed items — idempotent, locks lifted for these calls
app(DriveFolders::class)->ensure($drive, 'fy:2026/sales', 'Ventes', parent: $year, locked: true, sealed: true);
$drives->folderByKey($drive, 'fy:2026/sales'); $drives->fileByKey($drive, $key);
$files->link($drive, $invoice, $path, '20260115_acme_F-1.pdf', folders: [$month], disk: null, mimetype: 'application/pdf');
$drives->linksOf($invoice, $driveOrNull); $files->unlink($invoice, $driveOrNull); // the object stays
$files->notice($drive, $folderOrNull, $markdown, 'Notice.md'); // rewritten when changed
DriveLocks::bypass(fn () => app(DriveFolders::class)->delete($folder));
```
Sharing (read-only, subfolders and files included, nothing while the folder is trashed):
```php
$shares = app(DriveShares::class);
$shares->share($folder, $team, $by); $shares->unshare($folder, $team); $shares->sync($folder, [$a, $b]);
$shares->targets($user, $folder); // who the user may share with: bound ShareTargets (`sharing.targets`)
$shares->sharedWith($user); // topmost folders shared with the user's current entity
$shares->reaches($user, $folderOrFile); // the policy does NOT know about shares: check this for non-members
$shares->contents($root, $folder); // breadcrumbs cut at the shared root
$links = app(DriveLinks::class);
$link = $links->open($folder); $links->setPassword($link, $pw); $links->setExpiry($link, $date);
$links->regenerate($link); $links->revoke($folder); $links->find($token); $links->checkPassword($link, $pw);
$links->recordView($link); $link->unlockKey(); // keep unlockKey() in the visitor's session
app(DriveArchives::class)->download($folder); // ZIP, max links.archive_max_bytes
```
The host serves the public page (`links.route` names its route, `token` parameter).
Failures throw `OiLab\OiLaravelDrive\Exceptions\DriveException` (`rule`, `status`, French message), rendered
as `{error, message}` JSON on API requests.
Authorization: every folder/file is authorized through its drive — `Gate::authorize('view'|'update', $drive)`,
`create` (class) and `delete` only in multiple mode. Default `DrivePolicy`: member of the entity, from that
entity when `driveable.current_only`.
Access contract (`OiLab\OiLaravelDrive\Contracts\DriveableResolver`): `current(User): ?Model` and
`isMember(User, Model): bool`. The default `ConfigurableDriveableResolver` reads `driveable.current` on the
user and `driveable.members` on the entity.
## MCP Tools
```php
use OiLab\OiLaravelDrive\Mcp\DriveTools;
$this->tools = [...DriveTools::all(), ...]; // in the host's laravel/mcp Server::boot()
// add DriveTools::INSTRUCTIONS to the server instructions
```
Tools (base class `Mcp\Tools\DriveTool`, caller = token user, `drive_id` optional): `list-drive-folder`,
`search-drive`, `get-drive-file`, `list-drive-trash`, `get-drive-upload-status`, `create-drive-folder`,
`update-drive-folder`, `delete-drive-folder`, `request-drive-upload`, `upload-drive-file`, `update-drive-file`,
`delete-drive-file`, `restore-drive-item`, `get-drive-folder-sharing`, `share-drive-folder`,
`update-drive-folder-link` (+ `list-drives` in multiple mode). `mcp.naming = snake` gives
`list_drive_folder`. A host with its own tool base class can wrap them: each tool's `name()`, `description()`,
`schema()` and `handle()` are public.
Uploads: `request-drive-upload` opens a `DriveUpload`; the file is PUT raw to the signed `drive.uploads.put`
route (or dropped on the signed `drive.uploads.page` page) and becomes a drive file at once.
## Events
`OiLab\OiLaravelDrive\Events\*`: `DriveCreated|Updated|Deleted`, `DriveFolderCreated|Updated|Moved|Trashed|
Restored|Deleted`, `DriveFileCreated|Updated|Trashed|Restored|Deleted`, `DriveFileAttached|Detached`,
`DriveEntryOwnerChanged`, `DriveFolderShared|Unshared`, `DriveFolderLinkSaved|Revoked`. `*Deleted` events carry Data snapshots (`DriveFolderData`, `DriveFileData`,
`DriveData`). Use them for Scout / oi-laravel-raggable indexing.
## Commands
```bash
php artisan drive:purge-trash [--days=N] # daily when trash.purge_after_days is set
php artisan drive:prune-uploads [--days=7] # hourly when uploads.enabled
```
## Configuration
`config/oi-laravel-drive.php` (`php artisan vendor:publish --tag=oi-laravel-drive-config`):
| Key | Default | Env |
|-----|---------|-----|
| `user_model`, `user_name_attribute` | `App\Models\User`, `name` | |
| `driveable.resolver` / `current` / `members` / `current_only` | configurable resolver, `currentTeam`, `users`, `true` | `OI_DRIVE_CURRENT_ATTRIBUTE`, `OI_DRIVE_MEMBERS_RELATION` |
| `policy` | `DrivePolicy` | |
| `drives.multiple` / `default_name` | `false` / `Documents` | `OI_DRIVE_MULTIPLE`, `OI_DRIVE_DEFAULT_NAME` |
| `folders.unique_names` | `false` | `OI_DRIVE_UNIQUE_FOLDER_NAMES` |
| `files.disk` / `directory` / `max_bytes` / `allowed_mimes` | attachments disk, `drive`, 50 MB, `null` | `OI_DRIVE_DISK`, `OI_DRIVE_MAX_BYTES` |
| `files.disk_temporary_urls` / `temporary_url_minutes` | `true` / `30` | |
| `trash.enabled` / `purge_after_days` | `true` / `null` | `OI_DRIVE_TRASH`, `OI_DRIVE_TRASH_PURGE_DAYS` |
| `api.enabled` / `prefix` / `name` / `middleware` | `true`, `api/drive`, `drive.api.`, `['api','auth:sanctum']` | `OI_DRIVE_API` |
| `signed_routes.prefix` / `name` / `middleware` | `drive`, `drive.`, `['throttle:60,1']` | |
| `uploads.enabled` / `ttl_minutes` / `inline_max_bytes` | `true`, `10`, 1 MB | `OI_DRIVE_UPLOADS` |
| `sharing.model` / `name_attribute` / `targets` | `null` (the drive's entity class) / `name` / `AnyShareTarget` | |
| `links.route` / `password_min_length` / `archive_max_bytes` | `null`, `6`, 500 MB | `OI_DRIVE_LINK_ROUTE`, `OI_DRIVE_ARCHIVE_MAX_BYTES` |
| `mcp.naming` | `kebab` | `OI_DRIVE_MCP_NAMING` |
## Host Integration Checklist
1. `composer require oi-lab/oi-laravel-drive` (pulls oi-laravel-attachments), publish config, `php artisan migrate`.
2. Set `driveable.current` / `driveable.members` (or bind a `DriveableResolver`), and the `user_model`.
3. Add morph map entries for `Drive`, the folder/file models and the entity if the app enforces a morph map.
4. Keep the package API (`api.middleware` = the app's API stack) or set `api.enabled = false` and call the services.
5. Register `DriveTools::all()` + `DriveTools::INSTRUCTIONS` in the MCP server.
6. Override the policy (`policy`) when roles restrict writing.
7. Listen to the `Drive*` events for indexing, notifications or audit.
## Updating the AI Skill
After changing the package, update `resources/stubs/ai-skill.md`, run `composer sync-ai-skills`, then in host
apps run `php artisan oi:skills oilab-laravel-drive --project`.
oi-lab/oi-laravel-drive gives any entity of the app (a Team, an Organization…) a drive: a tree of folders
and files. It reuses the Folder/File/Attachment models of oi-laravel-attachments and adds the entity,
an owner, a date of creation, a trash, a JSON API, MCP tools and events.
| Model | Table | Notes |
|---|---|---|
OiLab\OiLaravelDrive\Models\Drive |
drives |
driveable morph (the entity), name, user_id (owner), UUID route key |
OiLab\OiLaravelDrive\Models\DriveEntry |
drive_entries |
One per folder/file: drive_id, entry morph, kind, user_id (owner), created_at, trashed_with_folder_id; managed items: key (unique per drive), role (notice), source morph (linked file), locked, sealed |
attachments Folder |
folders |
Tree via parent_id; soft deletes = trash |
attachments File |
files |
Stored on files.disk under drive/{drive uuid}/; soft deletes = trash |
attachments Attachment |
attachments |
Collection drive: files a file in a folder (attachable = folder) |
OiLab\OiLaravelDrive\Models\DriveUpload |
drive_uploads |
One-time upload link opened by an assistant |
OiLab\OiLaravelDrive\Models\DriveFolderShare |
drive_folder_shares |
Folder shared read-only with another entity (shareable morph) |
OiLab\OiLaravelDrive\Models\DriveFolderLink |
drive_folder_links |
Public link of a folder: token, hashed password, expires_at, views_count |
Runtime relations added to the attachments models (no change to their classes):
$folder->driveEntry, $file->driveEntry, $file->driveFolders (active folders only), $folder->driveFiles,
$folder->driveShares, $folder->driveLink.
Rules:
detach.withFiles also takes the files filed only in that tree;
shared files are only taken out. Without withFiles, the files stay (at the root if filed nowhere else).trashed_with_folder_id) comes back
with it. force or trash.enabled = false deletes for good (storage included).id) are UUIDs.locked item cannot be renamed, moved or deleted, nor a
folder holding one; a sealed folder takes no folder or file and lets none out. Users get a 403
DriveException (locked, locked_content, sealed); the host changes its items inside
DriveLocks::bypass(fn () => …).link()) shows the stored object of another model: no copy, and deleting the link
(trash purge included) never deletes the object. A notice is a locked Notice.md explaining its
folder, given with its text in DriveListingData::notice.use OiLab\OiLaravelDrive\Services\DriveManager;
use OiLab\OiLaravelDrive\Services\DriveFolders;
use OiLab\OiLaravelDrive\Services\DriveFiles;
use OiLab\OiLaravelDrive\Services\DrivePresenter;
$drives = app(DriveManager::class);
$drive = $drives->driveFor($team); // default drive, created on first use
$drive = $drives->resolveDrive($user, $uuid); // by UUID, or the user's current entity's default drive
$folder = app(DriveFolders::class)->create($drive, 'Clients', parent: null, owner: $user, createdAt: null);
app(DriveFolders::class)->update($folder, name: 'Clients 2026');
app(DriveFolders::class)->move($folder, $parentOrNull);
app(DriveFolders::class)->delete($folder, withFiles: false, force: false);
app(DriveFolders::class)->restore($folder);
$files = app(DriveFiles::class);
$file = $files->store($drive, $uploadedFile, folders: [$folder], owner: null, createdAt: null, title: null, description: null);
$file = $files->storeContent($drive, $content, 'notes.md', folders: []);
$files->update($file, ['name' => 'x.md', 'title' => '…', 'description' => '…']);
$files->attach($file, [$a, $b]); $files->detach($file, [$a]); $files->sync($file, []);
$files->delete($file); $files->restore($file);
$files->downloadUrl($file); // ['url' => …, 'expires_at' => …]
$files->text($file); // ['text' => …, 'truncated' => bool] or null (binary)
$drives->contents($drive, $folderOrNull); // DriveListingData
$drives->search($drive, 'term'); // DriveItemsData
$drives->trash($drive); $drives->emptyTrash($drive); $drives->purgeTrash($days);
$drives->setOwner($folderOrFile, $userOrNull); $drives->setCreatedAt($folderOrFile, $date);
$drives->findFolder($uuid, withTrashed: false); $drives->findFile($uuid); $drives->driveOf($item);
app(DrivePresenter::class)->file($file); // DriveFileData (folder_ids, owner, created_at…)
// Managed items — idempotent, locks lifted for these calls
app(DriveFolders::class)->ensure($drive, 'fy:2026/sales', 'Ventes', parent: $year, locked: true, sealed: true);
$drives->folderByKey($drive, 'fy:2026/sales'); $drives->fileByKey($drive, $key);
$files->link($drive, $invoice, $path, '20260115_acme_F-1.pdf', folders: [$month], disk: null, mimetype: 'application/pdf');
$drives->linksOf($invoice, $driveOrNull); $files->unlink($invoice, $driveOrNull); // the object stays
$files->notice($drive, $folderOrNull, $markdown, 'Notice.md'); // rewritten when changed
DriveLocks::bypass(fn () => app(DriveFolders::class)->delete($folder));
Sharing (read-only, subfolders and files included, nothing while the folder is trashed):
$shares = app(DriveShares::class);
$shares->share($folder, $team, $by); $shares->unshare($folder, $team); $shares->sync($folder, [$a, $b]);
$shares->targets($user, $folder); // who the user may share with: bound ShareTargets (`sharing.targets`)
$shares->sharedWith($user); // topmost folders shared with the user's current entity
$shares->reaches($user, $folderOrFile); // the policy does NOT know about shares: check this for non-members
$shares->contents($root, $folder); // breadcrumbs cut at the shared root
$links = app(DriveLinks::class);
$link = $links->open($folder); $links->setPassword($link, $pw); $links->setExpiry($link, $date);
$links->regenerate($link); $links->revoke($folder); $links->find($token); $links->checkPassword($link, $pw);
$links->recordView($link); $link->unlockKey(); // keep unlockKey() in the visitor's session
app(DriveArchives::class)->download($folder); // ZIP, max links.archive_max_bytes
The host serves the public page (links.route names its route, token parameter).
Failures throw OiLab\OiLaravelDrive\Exceptions\DriveException (rule, status, French message), rendered
as {error, message} JSON on API requests.
Authorization: every folder/file is authorized through its drive — Gate::authorize('view'|'update', $drive),
create (class) and delete only in multiple mode. Default DrivePolicy: member of the entity, from that
entity when driveable.current_only.
Access contract (OiLab\OiLaravelDrive\Contracts\DriveableResolver): current(User): ?Model and
isMember(User, Model): bool. The default ConfigurableDriveableResolver reads driveable.current on the
user and driveable.members on the entity.
use OiLab\OiLaravelDrive\Mcp\DriveTools;
$this->tools = [...DriveTools::all(), ...]; // in the host's laravel/mcp Server::boot()
// add DriveTools::INSTRUCTIONS to the server instructions
Tools (base class Mcp\Tools\DriveTool, caller = token user, drive_id optional): list-drive-folder,
search-drive, get-drive-file, list-drive-trash, get-drive-upload-status, create-drive-folder,
update-drive-folder, delete-drive-folder, request-drive-upload, upload-drive-file, update-drive-file,
delete-drive-file, restore-drive-item, get-drive-folder-sharing, share-drive-folder,
update-drive-folder-link (+ list-drives in multiple mode). mcp.naming = snake gives
list_drive_folder. A host with its own tool base class can wrap them: each tool's name(), description(),
schema() and handle() are public.
Uploads: request-drive-upload opens a DriveUpload; the file is PUT raw to the signed drive.uploads.put
route (or dropped on the signed drive.uploads.page page) and becomes a drive file at once.
OiLab\OiLaravelDrive\Events\*: DriveCreated|Updated|Deleted, DriveFolderCreated|Updated|Moved|Trashed| Restored|Deleted, DriveFileCreated|Updated|Trashed|Restored|Deleted, DriveFileAttached|Detached,
DriveEntryOwnerChanged, DriveFolderShared|Unshared, DriveFolderLinkSaved|Revoked. *Deleted events carry Data snapshots (DriveFolderData, DriveFileData,
DriveData). Use them for Scout / oi-laravel-raggable indexing.
php artisan drive:purge-trash [--days=N] # daily when trash.purge_after_days is set
php artisan drive:prune-uploads [--days=7] # hourly when uploads.enabled
config/oi-laravel-drive.php (php artisan vendor:publish --tag=oi-laravel-drive-config):
| Key | Default | Env |
|---|---|---|
user_model, user_name_attribute |
App\Models\User, name |
|
driveable.resolver / current / members / current_only |
configurable resolver, currentTeam, users, true |
OI_DRIVE_CURRENT_ATTRIBUTE, OI_DRIVE_MEMBERS_RELATION |
policy |
DrivePolicy |
|
drives.multiple / default_name |
false / Documents |
OI_DRIVE_MULTIPLE, OI_DRIVE_DEFAULT_NAME |
folders.unique_names |
false |
OI_DRIVE_UNIQUE_FOLDER_NAMES |
files.disk / directory / max_bytes / allowed_mimes |
attachments disk, drive, 50 MB, null |
OI_DRIVE_DISK, OI_DRIVE_MAX_BYTES |
files.disk_temporary_urls / temporary_url_minutes |
true / 30 |
|
trash.enabled / purge_after_days |
true / null |
OI_DRIVE_TRASH, OI_DRIVE_TRASH_PURGE_DAYS |
api.enabled / prefix / name / middleware |
true, api/drive, drive.api., ['api','auth:sanctum'] |
OI_DRIVE_API |
signed_routes.prefix / name / middleware |
drive, drive., ['throttle:60,1'] |
|
uploads.enabled / ttl_minutes / inline_max_bytes |
true, 10, 1 MB |
OI_DRIVE_UPLOADS |
sharing.model / name_attribute / targets |
null (the drive's entity class) / name / AnyShareTarget |
|
links.route / password_min_length / archive_max_bytes |
null, 6, 500 MB |
OI_DRIVE_LINK_ROUTE, OI_DRIVE_ARCHIVE_MAX_BYTES |
mcp.naming |
kebab |
OI_DRIVE_MCP_NAMING |
composer require oi-lab/oi-laravel-drive (pulls oi-laravel-attachments), publish config, php artisan migrate.driveable.current / driveable.members (or bind a DriveableResolver), and the user_model.Drive, the folder/file models and the entity if the app enforces a morph map.api.middleware = the app's API stack) or set api.enabled = false and call the services.DriveTools::all() + DriveTools::INSTRUCTIONS in the MCP server.policy) when roles restrict writing.Drive* events for indexing, notifications or audit.After changing the package, update resources/stubs/ai-skill.md, run composer sync-ai-skills, then in host
apps run php artisan oi:skills oilab-laravel-drive --project.
How can I help you explore Laravel packages today?