writeContent deprecated in v5

In v4 I often did something like this:

    // 1. load json file containing an array of page contents
    // 2. search and update corresponding element in array
    // 3. save json file
    public function writeContent(array $data, ?string $languageCode = null): bool
    {
        $patch = array_filter([
            'only' => $data['price'] ?? null, 
            'some' => $data['some'] ?? null,
            'fields' => $data['fields'] ?? null,
            'allowed' => $data['allowed'] ?? null,
        ], fn($v) => $v !== null);

        $filePath = "path/to/json-file.json";
        $allPages = json_decode(file_get_contents($filePath), true);
        $itemIndex = array_search(
                $this->ref()->value(),
                array_column($allPages, 'ref')
        );
        $allPages[$itemIndex] = array_replace($allPages[$itemIndex], $data);
        file_put_contents($filePath, json_encode($allPages));
        return false;
    }

In Kirby 5, writeContent is deprecated. This is what the changelog has to say:

  • Extending the internal $model->contentFile(), $model->contentFiles(), $model->contentFileDirectory(), $model->contentFileExtension(), $model->contentFileName(), $model->readContent() and $model->writeContent() methods in a page model will no longer have an effect as these methods are no longer called by the core. Please extend the new Kirby\Content\PlainTextContentStorageHandler class instead and return an instance of your custom class from $model->storage(). Please note that the interface of PlainTextContentStorageHandler is internal and may change in the future.

(emphasis mine)

Now, I’ve tried extending PlainTextContentStorageHandler but I can’t get it to actually save anything. No “changes” version and no “latest” version. My write() is never called (like, I put a die("hello world") in it and it’s never called).

The storage class roughly looks like this:

class MyStorage extends PlainTextStorage {
    protected static array $fileCache = [];

    public function __construct(MyPage $page, protected string $ref) {
        parent::__construct($page);
    }

    protected function contentDirectory(VersionId $versionId): string
    {
        return "path/to";
    }

    public function contentFile(VersionId $versionId, Language $language): string
    {
        $filename = match ($versionId->value()) {
            VersionId::LATEST => 'json-file.json',
            VersionId::CHANGES => 'json-file-changes.json',
        };
        return $this->contentDirectory($versionId) . '/' . $filename;
    }

    public function delete(VersionId $versionId, Language $language): void {
        throw new Exception('this must never happen');
    }

    public function exists(VersionId $versionId, Language $language): bool {
        // this is what the original code looks like if you only have 1 file and no languages
        return $versionId->is(VersionId::LATEST);
    }

    // new utility function to read and cache the json file
    protected function readContentFile(VersionId $versionId, Language $language): array {
        $contentFile = $this->contentFile($versionId, $language);

        if (!file_exists($contentFile)) {
            return [];
        }
        $json = file_get_contents($contentFile);
        if ($json === false) {
            return [];
        }
        return self::$fileCache[$versionId->value()] = json_decode($json, true);
    }

    public function read(VersionId $versionId, Language $language): array {
        if($versionId->is(VersionId::LATEST)) {
            $content = self::$fileCache[$versionId->value()] ?? $this->readContentFile($versionId, $language);
            $index = array_search($this->ref, array_column($content, 'ref'));
            if($index === false) throw new Exception('Page not found: ' . $this->ref);
            return $content[$index];
        }
        
        // changes version merges the latest version with the changes
        $latest = self::$fileCache[VersionId::LATEST] ?? $this->readContentFile(VersionId::latest(), $language);
        $changes = self::$fileCache[VersionId::CHANGES] ?? $this->readContentFile(VersionId::changes(), $language);
        $index_latest = array_search($this->ref, array_column($latest, 'ref'));
        $index_changes = array_search($this->ref, array_column($changes, 'ref'));
        if($index_latest === false && $index_changes === false) throw new Exception('Page not found: ' . $this->ref);
        if($index_latest === false) return $changes[$index_changes];
        if($index_changes === false) return $latest[$index_latest];
        return array_replace($latest[$index_latest], $changes[$index_changes]);
    }

    public function write(VersionId $versionId, Language $language, array $fields): void {
        $patch = array_filter([
            'only' => $fields['price'] ?? null, 
            'some' => $fields['some'] ?? null,
            'fields' => $fields['fields'] ?? null,
            'allowed' => $fields['allowed'] ?? null,
        ], fn($v) => $v !== null);

        $data = self::$fileCache[$versionId->value()] ?? $this->readContentFile($versionId, $language);

        $index = array_search($this->ref, array_column($data, 'ref'));
        if($index === false) throw new Exception('Apartment not found: ' . $this->ref);

        $data[$index] = array_merge($data[$index], $patch);
        self::$fileCache[$versionId->value()] = $data;
        file_put_contents($this->contentFile($versionId, $language), json_encode($data));
    }
}

The model looks like this:

    public function storage(): Storage
    {
        return $this->storage ??= new MyStorage($this, $this->_ref);
    }

Is there an example of how to update virtual pages with the panel?

So, I’ve been looking at the stuff that calls my function storage() and what it returns.

When doing an edit, it’s getting called exactly once; by ModelWithContent::changeStorage.

That happens essentially in the constructor of the model. The calls go something like this:

 - ...
 - MyPage::__construct()
 - ModelWithContent::__construct()
 - ModelWithContent::setContent()
 - ModelWithContent::changeStorage() <- here the storage is changed to MemoryStorage

After that it only returns MemoryStorage.

So I guess Kirby is switching out my storage and never gives it back, and that’s supposedly why my write function never gets called.
How can I make it go back to MyStorage?

Things I’ve learned:

If you want to use the storage stuff (and I don’t think there’s a way around that): don’t pass content or translations props to the model constructor.
Otherwise Kirby will switch out your storage handler with a MemoryStorage.

Kirby will need to be able to instantiate both your Page model as well as your Storage, this means you can only use the __construct signature Kirby expects:

// MyStorage
public function __construct(Page $page);
// MyPage
public function __construct(array $props);

No additional parameters allowed. If your model needs more infomation to construct itself, you’ll have to work around that. Also remember that you can’t store that info in 'content' (because above).
Like in my case above, I need $this->_ref to identify the object in the json file. I needed to reconstruct that reference number from the slug.