The other foundation chapters focused on writing data: schema setup, item save, validation. This chapter covers what happens afterwards: how to update a row, how to delete one, what cascades automatically, what doesn't, and how to wire up application-level cleanup (physical file removal, cache invalidation) through the event system.
Mini-case throughout: a single-user bookmark vault with one
category (Bookmark) holding title, url, and cover_image
fields. Small enough to fit in one head, has uploaded files so the
deletion stories have something concrete to clean up.
Every iManager repository (CategoryRepository, FieldRepository,
ItemRepository, FileRepository) speaks the same three verbs:
$repo->save($value); // insert if id is null, update if id is set
$repo->ensure($value); // upsert by natural key (Category + Field only)
$repo->delete(int $id); // remove by primary keyPlus the read verbs (find, findBy…, findAll, query) that
return value objects to feed back in.
This chapter is mostly about the second life of those value objects: what happens after they exist.
Build the schema once, the same shape as the schema chapter:
require __DIR__ . '/vendor/autoload.php';
use Imanager\DefaultBootstrap;
use Imanager\Domain\Category;
use Imanager\Domain\Field;
use Imanager\Domain\Item;
use Imanager\Storage\CategoryRepository;
use Imanager\Storage\FieldRepository;
use Imanager\Storage\ItemRepository;
$container = DefaultBootstrap::boot(
databasePath: __DIR__ . '/data/bookmarks.db',
uploadsPath: __DIR__ . '/data/uploads',
uploadsUrl: '/uploads',
cachePath: __DIR__ . '/data/cache',
);
$categories = $container->get(CategoryRepository::class);
$fields = $container->get(FieldRepository::class);
$items = $container->get(ItemRepository::class);
$bookmark = $categories->ensure(new Category(null, 'Bookmark', 'bookmark'));
$fields->ensure(Field::text($bookmark->id, 'title', 'Title')->required()->maxLength(200));
$fields->ensure(Field::text($bookmark->id, 'url', 'URL')->required()->maxLength(2000));
$fields->ensure(
Field::image($bookmark->id, 'cover_image', 'Cover')
->maxBytes(2_000_000)->mimes('image/jpeg', 'image/png'),
);
$saved = $items->save(new Item(
null, $bookmark->id, 'imanager-docs', 'iManager Documentation',
data: ['title' => 'iManager Documentation', 'url' => 'https://github.com/bigin/imanager'],
));You now have one Bookmark item with id 1. The rest of the chapter
mutates and removes it.
The most common shape: fetch the existing value object, derive a new one with the change you want, write it back.
$existing = $items->find($saved->id);
\assert($existing !== null);
$updated = $items->save(new Item(
id: $existing->id, // keep the id ⇒ this is an update
categoryId: $existing->categoryId,
name: $existing->name,
label: 'iManager Library Documentation', // ← the change
position: $existing->position,
active: $existing->active,
data: $existing->data,
));save() distinguishes insert from update by whether $item->id is
null. With an id present, it issues UPDATE items SET … WHERE id = :id and fires ItemUpdated. With id = null, it issues INSERT
and fires ItemCreated.
For Field (which got fluent setters in 2.1.0), the same pattern
is one expression:
$titleField = $fields->findByName($bookmark->id, 'title');
\assert($titleField !== null);
$fields->save(
$titleField->maxLength(500)->placeholder('Page title…'),
);Each setter returns a new final readonly Field with the existing
id preserved. save() sees the id and does an update.
(For Item, there's a similar $item->data->with('key', $value)
on the FieldValueBag; see docs/api/domain.md
for the bag's with() / without() / merge() helpers.)
ensure() is deliberately insert-only on the natural-key path.
This is the safety promise: a silently-flipped indexed flag
would trigger an ALTER TABLE you didn't ask for. So if you want
an idempotent setup script that also applies flag updates on
re-run, you compose:
$existing = $fields->findByName($bookmark->id, 'title');
$fields->save(
$existing
? $existing->maxLength(500) // update path
: Field::text($bookmark->id, 'title', 'Title')->maxLength(500),
);Verbose, but explicit about what's an update.
Every successful update emits its *Updated event carrying both
the previous and the new value object, so listeners can diff:
use Imanager\Domain\Event\FieldUpdated;
use Imanager\Events\SubscriberListenerProvider;
$provider = $container->get(SubscriberListenerProvider::class);
$provider->subscribe(FieldUpdated::class, function (FieldUpdated $e): void {
if ($e->previous->maxLength !== $e->current->maxLength) {
echo "Field {$e->current->name}: maxLength {$e->previous->maxLength}"
. " → {$e->current->maxLength}\n";
}
});Same shape for CategoryUpdated (previous, current) and
ItemUpdated (previous, current).
The interesting work. Deleting in iManager isn't just a SQL DELETE. It's a cascade with carefully-ordered events, and a few things that don't happen automatically that you have to know about.
$categories->delete($bookmark->id);What runs, in order:
- Lookup:
find($id)confirms the row exists; raisesNotFoundExceptionif not (no silent no-op delete). CategoryDeletedevent fires. Crucially, before the SQL delete, so listeners can still walk into the soon-to-be-gone children if they need to. Listeners that need fields / items / files for cleanup have one shot here.DELETE FROM categories WHERE id = :idruns.- SQLite's foreign-key cascade kicks in:
- All
fieldsrows withcategory_id = $id→ gone. - All
itemsrows withcategory_id = $id→ gone. - All
filesrows referencing those items → gone (transitive cascade throughitems.id).
- All
- The indexed-field generated columns are NOT cleaned up. ← see the caveat section below.
$fields->delete($titleField->id);What runs:
- Lookup or
NotFoundException. FieldDeletedevent fires with the field'sid,categoryId, andname(because by event time you couldn't recover those by lookup anymore).DELETE FROM fields WHERE id = :id.- If
$field->indexed === true, the matching generated column and its index get dropped:ALTER TABLE items DROP COLUMN gen_<catId>_<fieldName>plus the matchingDROP INDEX. This is the ONLY path that cleans up generated columns; see the caveat below. - The field's JSON key in existing items'
datacolumns is NOT touched. Item rows still carry{"title": "..."}for items created before deletion. There's just no schema entry for it anymore. Items become valid (any key, no validation), the values simply orphan.
$items->delete($saved->id);What runs:
- Lookup or
NotFoundException. ItemDeletedevent fires with the item'sidandcategoryId. Before the SQL DELETE, intentionally, so listeners can still call$files->findByItem($id)to walk physical-file metadata before the FK cascade flattens it.DELETE FROM items WHERE id = :id.DELETE FROM items_fts WHERE rowid = :idkeeps the FTS index in sync.- SQLite cascade: all
filesrows for this item → gone.
The deliberate event-before-delete ordering for the Deleted events
is the difference between iManager and a naive ORM: by the time
your listener runs, the row is still readable. You don't need to
denormalize "what files did this item have" into the event payload;
you can just look them up.
Two situations where the automatic cascades are weaker than they look. Worth knowing before you build a long-lived install.
When you delete a field explicitly via $fields->delete($id),
iManager calls IndexedFields::drop() to remove the generated
column from the items table.
When you delete a category via $categories->delete($id), the
fields are removed via SQLite FK cascade. But that cascade goes
straight through the database, not through SqliteFieldRepository.
So IndexedFields::drop() is not invoked. The generated columns
sit on the items table forever, referencing data keys for
items that no longer exist. They compute to NULL for all
remaining rows and waste a little schema overhead.
Workaround: enumerate the category's fields and delete them
explicitly before deleting the category, so each field's
delete() path runs the proper cleanup:
foreach ($fields->findByCategory($bookmark->id) as $field) {
$fields->delete($field->id);
}
$categories->delete($bookmark->id);A future iManager release may close this loop via a
CategoryDeleted listener wired into DefaultBootstrap; for now
the manual loop is the honest answer.
The files SQL table cascades cleanly: a deleted item → its file
metadata rows → gone. But the physical bytes on disk (the
<uploadsPath>/<itemId>/<fieldId>/photo.jpg files written by the
upload handler) are NOT touched by iManager's storage layer. The
database happily forgets the metadata; the bytes orphan on disk.
This is by design: iManager doesn't assume the FileStorage backend
is local-disk; it could be S3, a CDN, a CAS store. Deletion through
those backends has implementation-specific semantics, so iManager
delegates the cleanup to a host-supplied listener subscribed
to ItemDeleted. The next section shows the canonical shape.
The pattern: subscribe an ItemDeleted listener, walk the item's
files via FileRepository, delete each via FileStorage.
use Imanager\Domain\Event\ItemDeleted;
use Imanager\Events\SubscriberListenerProvider;
use Imanager\Files\FileStorage;
use Imanager\Files\FileStorageException;
use Imanager\Storage\FileRepository;
$provider = $container->get(SubscriberListenerProvider::class);
$files = $container->get(FileRepository::class);
$storage = $container->get(FileStorage::class);
$provider->subscribe(
ItemDeleted::class,
function (ItemDeleted $event) use ($files, $storage): void {
foreach ($files->findByItem($event->itemId) as $file) {
try {
$storage->delete($file->path);
} catch (FileStorageException) {
// Already gone, or transient I/O failure. Log + move on.
// Raising here would abort the SQL DELETE that follows the
// event (see "exceptions" below).
}
}
},
);What this gets you:
- Every
$items->delete($id)triggers the listener. - The listener runs before the FK cascade flattens the file
metadata, so
$files->findByItem()still returns the rows. - For each file,
$storage->delete($file->path)removes the physical bytes. - After the listener returns, the SQL DELETE runs and the file metadata rows go with the item via FK cascade.
The same pattern works for FieldDeleted (when you remove a field
that owned upload-type values). The field-delete path is
less common than item-delete, and the cleanup walk would use
FileRepository::findByItemAndField() on a per-item loop.
iManager's SyncEventDispatcher does not swallow listener
exceptions: they propagate up. For Deleted events the dispatch
happens before the SQL delete, so a throwing listener leaves the
row intact in the database. That's a feature, not a bug: a failed
file cleanup shouldn't be quietly papered over by a successful row
delete that orphans the files.
Practical consequence: keep listener bodies defensive. The
try/catch in the example above swallows FileStorageException so
"file already gone" doesn't poison the whole delete. Anything more
serious you'd let propagate.
iManager has no deleted_at / is_deleted mechanism baked in.
delete() is a hard delete; the row is gone, the event fires once.
Most reasons to want soft-delete map to one of three patterns:
- "Hide without removing": use the existing
Item::$activeboolean. Default queries can filter foractive = true; admin views can show inactive too. No data loss, no cascade headaches. - "Restore-on-undo": keep a separate audit/snapshot table
populated from
ItemUpdatedandItemDeletedlisteners. The iManager core doesn't enforce a shape; you control it. - "Compliance / retention window": a scheduled job runs
$items->delete($id)after the retention period; until then, useactive = false.
If you genuinely need soft-delete at the model layer (e.g., for
referential integrity reasons), the lightest implementation is a
custom field of type Datepicker named deleted_at, plus a
Query::where('deleted_at', '=', null) clause baked into your read
paths.
You walked through how iManager mutates state: the three repository
verbs (save, ensure, delete), the events each emits, and the
ordering quirks that matter when listeners need to read state
on the way out. You learned that SQLite FK cascades handle the
metadata tables (categories → fields, items, files) automatically
but leave two real-world gaps: indexed generated columns aren't
cleaned up when a category is deleted via cascade, and physical
file bytes survive an ItemDeleted unless a host listener tears
them down. You saw the canonical file-cleanup listener using
ItemDeleted + FileRepository::findByItem + FileStorage::delete,
and read why iManager has no soft-delete baked in (it doesn't need
one; Item::$active handles the most common cases).
docs/api/domain.md, every event with its payload shape (*Created,*Updated,*Deleted× Category / Field / Item, plus the markerDomainEventinterface).docs/api/storage.md, repository contracts forsave(),ensure(),delete().src/Files/FileStorage.php, the file-bytes-backend interface;LocalFileStorageis the default, swap for S3-shaped backends by registering a different binding in your bootstrap.src/Events/SyncEventDispatcher.php, the in-process PSR-14 dispatcher iManager wires by default, with notes on its sync + propagate-exception semantics.