🚀 A new era: Kirby 4 Get to know
Skip to content

Content from a spreadsheet

Sometimes Excel or Numbers are fantastic content management systems. In this example we will create pages from a simple CSV file that could come from any spreadsheet.

In this example we are going to read a list of (dummy) animals from a CSV file and create virtual pages for each animal.

Note: For this example we are using a .csvfile where fields are separated by a semi-colon. If you are using other separators in your own file, change the separator parameter when calling the csv() method below accordingly.

The parent content folder

Let's create an animals folder for our site, which is used as the parent for all animal subpages.

  • content
    • animals
      • animals.txt
      • animals.csv

We can put the animals.csv directly inside this folder to make it nice and clean to read data from that file. You could even provide an uploader for this in the panel later, so your content editors can update the csv file whenever they have a fresh export with updated data.

Reading from the CSV

To convert the CSV from the file to a PHP array we are going to use a little csv() helper function. This function is stored in a small plugin. It takes the absolute path to the csv file and returns a nice and clean array.

function csv(string $file, string $delimiter = ','): array
    $lines = file($file);

    $lines[0] = str_replace("\xEF\xBB\xBF", '', $lines[0]);

    $csv = array_map(function($d) use($delimiter) {
        return str_getcsv($d, $delimiter);
    }, $lines);

    array_walk($csv, function(&$a) use ($csv) {
       $a = array_combine($csv[0], $a);


    return $csv;

Creating the virtual subpages

We are using a new page model for the animals page, which will read from the csv and create a virtual child page for each animal on the fly.


use Kirby\Uuid\Uuid;

class AnimalsPage extends Page

    public function children(): Pages
        if ($this->children instanceof Pages) {
            return $this->children;

        $csv      = csv($this->root() . '/animals.csv', ';');
        $children = array_map(function ($animal) {
            return [
                'slug'     => Str::slug($animal['Scientific Name']),
                'template' => 'animal',
                'model'    => 'animal',
                'num'      => 0,
                'content'  => [
                    'title'       => $animal['Scientific Name'],
                    'commonName'  => $animal['Common Name'],
                    'description' => $animal['Description'],
                    'uuid'        => Uuid::generate(),

        }, $csv);

        return $this->children = Pages::factory($children, $this);


Unless you have disabled UUIDs in your config, you have to pass a uuid field in the content array to prevent Kirby from generating the page in the file system when the $page->uuid() method is called.

If you generate the UUIDs automatically like in the example above, they will change at every load. However, if you want to reference your virtual pages anywhere with their UUID, make sure to use a unique string that does not change.

The template

With the new page model, we can now render all animals in our animals.php template as if they were regular Kirby pages.

<?php snippet('header') ?>

  <h1><?= $page->title() ?></h1>

  <ul class="animals">
    <?php foreach ($page->children() as $animal): ?>
      <a href="<?= $animal->url() ?>">
        <?= $animal->title() ?>
    <?php endforeach ?>


<?php snippet('footer') ?>


Each animal will automatically get its own subpage. Routing will work out of the box and you can create an animal.php template to render each individual animal.

<?php snippet('header') ?>

<article class="animal">
  <h1 class="animal-scientific-name"><?= $page->title() ?></h1>
  <p class="animal-common-name">Common name: <?= $page->commonName() ?></p>
  <div class="animal-description">
    <?= $page->description()->kt() ?>

<?php snippet('footer') ?>