Laracrate Docs

Laracrate Docs adds native editable documents to Laracrate. A document is not a file you upload: it is a row in your drive with no binary behind it, the way Drive holds a Google Doc. The file row stays the index; the content, the versions and the named fields live apart.
A file store assumes the thing it keeps arrives finished. A document written in an editor arrives empty, changes every few seconds, and is never finished. Pushing one through an upload pipeline means writing a new object to your bucket on every keystroke, and then extracting the text you already had in memory.
Drive settled this years ago by keeping one index and two storages: a native Doc is a row in its files resource with mimeType = application/vnd.google-apps.document and zero bytes, while its content lives in the service. This package does the same on top of Laracrate, which is what keeps a folder listing a single query that sorts by name, size or date, paginates, and mixes uploaded PDFs with documents written in your app.
Installation
composer require edulazaro/laracrate-docs
php artisan migrate
Three tables are created with the laracrate_ prefix, next to the ones Laracrate owns. Their migrations are numbered to run after them, because laracrate_documents has a foreign key into laracrate_files.
Creating a document
use EduLazaro\LaracrateDocs\Actions\CreateDocumentAction; $document = app(CreateDocumentAction::class)->handle( title: 'Loan agreement (draft)', owner: $user, // who sees it in their drive fileable: $organization, // where it belongs creator: $user, folder: $folder, // optional );
The action deliberately skips addFile(). That method expects something to upload and there is nothing, so the file row is written directly, with coherent synthetic values for the six columns Laracrate requires to be present: disk, path, extension, mime_type, original_name and size. The path keeps the shape of a real one, which leaves the slot ready if you ever want to dump a snapshot there and stops a bucket listing from confusing whoever reads it.
It also sets processing_status from the start. That is the gate FileObserver::created() checks before launching Laracrate's pipeline, so nothing ever tries to read a binary that is not there.
Saving content
use EduLazaro\LaracrateDocs\Actions\SaveDocumentContentAction; app(SaveDocumentContentAction::class)->handle( $document, contentJson: $editorState, contentHtml: $renderedHtml, wordCount: 1240, );
The content lives in laracrate_documents, but the drive sorts by the file's updated_at and shows its size. Without keeping those current, a document written for an hour still lists as the 47 bytes it was created with, which is exactly what you look at to find what you were working on.
The file row is not touched on every save. Autosave fires several times a minute, and that would be two writes a second across two tables to move a timestamp nobody reads at that resolution. It is refreshed at most every thirty seconds, which in a listing is indistinguishable from doing it always.
Renaming goes through the document, not the file:
$document->rename('Loan agreement v2');
The file row has both name and title, and a drive sorts by one and displays the other. Letting every caller pick which to write is how they end up disagreeing.
Versions
$revision = $document->snapshot('before the AI edited it', $author); $document->revertTo($revision, $author);
Snapshots are milestones, not keystrokes. Autosave runs continuously; a version per sentence produces a history nobody can read. Take one when the editing session ends, when someone asks for it, and always before an agent writes: that last one is what lets you throw away what the model did without losing what the person wrote.
The author is polymorphic, so a version written by an agent is distinguishable from one written by a human. That is the first question anyone asks when something goes wrong.
revertTo() snapshots the current state before overwriting it, because rolling back should not be an elegant way to lose work. It is not called restore() on purpose: that name belongs to SoftDeletes, and the day someone adds the trait there would be two methods with the same name and opposite meanings.
Fields, and why they exist
A placeholder in the content stores a key and nothing else:
{ "type": "placeholder", "attrs": { "key": "minimum_fee" } }
The value is a row, with three possible origins:
source |
Where the value comes from |
|---|---|
manual |
someone types it |
binding |
pulled from your data, through expression, like client.tax_id |
formula |
computed from other fields, like principal*((1+rate)^(months/12)-1) |
$document->fields()->create([ 'key' => 'minimum_fee', 'label' => 'Minimum fee', 'type' => 'money', 'source' => 'formula', 'expression' => 'principal*((1+rate)^(months/12)-1)', ]); $document->missingFields()->count(); // what is still empty $document->isComplete(); // false while anything is missing
This table exists because of a real document. A legal draft generated with AI assistance carried a minimum fee of 7,292.10 € and, nine lines below, 7,292.11 € for the same amount, in the same document, right after announcing it had been corrected in both files. It was the figure someone was going to pay.
That happens because the number lives as repeated text and the correction depends on catching every occurrence. With the value in a row and the content referencing its key, the same key appears in three clauses and a table and is one row. The unique constraint on (document_id, key) makes a second value impossible by construction rather than by care.
isComplete() is what tells your export whether the PDF should carry a draft mark. A document with empty placeholders should not circulate looking final, and no word processor can warn you about it, because none of them knows a tax id is missing.
Listing a drive
Because the index is the file row, listing a folder is one query and the documents come along:
File::where('owner_type', 'user')->where('owner_id', $user->id) ->where('folder_id', $folder?->id) ->orderBy('name') ->paginate();
To go from a file to its content, Document::forFile($file). In a listing, eager load with with('file') or the title accessor gives you a silent N+1.
Exporting
The package does not render PDFs. It keeps content_html current, which is what any renderer needs, and that is the line it does not cross on purpose.
Pagination, headers and footers belong to the export, never to the editor. ProseMirror has no concept of a page, so paginating on screen means measuring heights and breaking the flow by hand, which is the deepest rabbit hole in this field. Let the renderer paginate, keep the header and footer as your own stationery, and the editor stays an editor.
What it does not do
It is not an editor. It stores whatever document JSON you hand it. The editor, its nodes and its toolbar are yours.
It does not know your domain. Clause numbering, legal citations, a tax id field type: those are extensions you register, the same way Tiptap keeps its core generic and lets you add the rest.
It does not do permissions. Who can read or edit is the file's business, and Laracrate's visibility column is a label your application has to enforce, not something the package checks for you.
It does not do real-time collaboration. Yjs can sit on top through the Tiptap extension, but be aware it wants to own the history, so the revisions table would hold its binary state instead of plain JSON. Worth deciding before you have a thousand documents.
built and maintained by Edu Lazaro · MIT license