Back to blog
laravel file-storage transactions soft-deletes validation

Evening session: a file area, and a delete you can undo

The Starting Point

Second session of the day, after the module-splitting one. Files. The delivery module in the source has folders and documents, and the record framework doesn't cover attachments, so this is real work rather than a declaration.

I'd built the storage contract a few days ago — modules never name a disk, they call FileStorage and the deployment picks. This is its second real caller.

What Actually Happened

Reading the source's download route was the moment the evening got interesting.

It takes a bare file identifier, checks only that the module is enabled, and streams the bytes. That's it. Any file in any project, readable by anyone who could reach the route. No ownership check at all.

The fix is nearly free — the owning identifier is on the row already, so re-establishing the owner from the URL and 404ing otherwise costs one comparison. But it only gets fixed if you actually read the controller instead of the migration, which is the lesson I keep re-learning on this port.

Then the transactional question, which has two different right answers.

Upload is transactional with the disk. The source wrote the bytes and then created the row, so a failed row write left a file nobody could see or delete — invisible waste accumulating forever. Bytes go first here, because object storage cannot roll back, and anything already written gets removed if the transaction fails. That's the rule I'd already set for tracker attachments, applied unchanged.

Delete is deliberately not transactional. Different costs, different rule: a file left on disk is waste; a folder that cannot be deleted because storage is briefly unavailable is a broken screen the user can't get past.

Same reasoning applied to the bin purge later: a purge that fails because storage blinked is a job that fails every night until somebody notices.

A guard that refuses everything passes its own test.

The move guard stops you moving a folder into its own descendant. The obvious test is "moving into itself is refused" — and a guard that refuses every move passes that test and is completely broken. So there's a second test asserting a legitimate move still works. That's the same shape as the cycle-check bug from two days ago and I'd like to think I'm learning.

The settings bug, found by being its own second caller.

I shipped the module settings mechanism that afternoon. A module declares a setting with validation rules, core renders and stores it. Fine with one setting.

Then the file area declared a second one — bin retention days — and neither setting could be saved. A module's required rule was being read as "every save must name every setting", so the moment there were two, each save failed validation on the one it wasn't changing.

Rules are prefixed with sometimes now, which is what required was always meant to mean in that context: required if present, not required on every request.

Classic. The mechanism was built and tested with exactly one caller, which is the number of callers that proves nothing.

What Worked

Deleting puts things in a bin. The source destroys immediately. "Deleted the wrong thing" is the commonest accident a file area has.

So deleting is soft and the bytes stay exactly where they are. A restore that brought back a row pointing at content already gone would be a worse outcome than the delete it was meant to undo.

Two edge cases that needed deciding rather than defaulting:

  • Deleting a folder takes its contents to the bin with it, explicitly. The database cascade only fires on a hard delete, so without doing this by hand a restored folder would come back empty.
  • A file restored while its folder is still binned comes back to the root, not into a folder nobody can see — where it would be recoverable in name only.

Retention is a deployment's decision. Default 90 days, configurable on the settings panel. Zero means keep indefinitely — a real answer rather than a hidden sentinel, because an organisation whose client documents must not disappear on a timer has to be able to say so. The help text says exactly that.

A daily command does the purging and reports what it did even when that is nothing, because a job that's silent on a quiet night is indistinguishable from one that isn't running.

What I Learned

  • A feature with one caller is untested, whatever the coverage says. My settings validation worked perfectly until a module declared a second setting, at which point neither could be saved.
  • "Required" in a partial-update context almost always means sometimes|required. Laravel will happily enforce the stricter reading and it looks like a form bug.
  • Transactional-ness is a per-operation judgement, not a policy. Upload rolls back because orphaned bytes are invisible; delete doesn't because a blocked delete is a broken screen. Writing down why stops the next person "fixing" the inconsistency.
  • A guard that refuses everything passes the test that it refuses the bad thing. Always test that the legitimate case still works.
  • Soft-delete a row, keep the bytes. A restore that resurrects a pointer to nothing is worse than the mistake it's undoing.
  • Zero as "off"/"forever" beats a null or a magic sentinel — as long as the UI says so where someone will read it.
  • Read the download route of any file feature you inherit. It's where authorisation goes to die.

Tech Notes

  • Stack: PHP 8.4, Laravel, MySQL (Docker), SQLite in-memory (package suite)
  • Upload: bytes to disk first, then a DB transaction; written bytes removed by hand if the transaction fails (object storage can't roll back)
  • Delete: soft on the row, bytes untouched; folder delete cascades to the bin explicitly
  • Retention setting files.bin_retention_days, default 90, 0 = keep indefinitely; daily console command purges and always logs its outcome
  • Validation rules from module setting declarations are now prefixed sometimes
# the purge command reports even on a quiet night — silence would look identical to "not running"
php artisan <module>:purge-file-bin
Zero Focus Dev

Zero Focus Dev

Breaking things, learning fast, sharing it all.