Summary

For Day 8 the goal is to turn a Shoot into a functional multi-page site.

Today’s Definition of Done:

  • pages table linked to sites table via site_id
  • Site model can have many Page models
  • Routing for pages works
  • Sub-pages can be viewed

Work Session

The first thing I did today is create a model using artisan for the Page model.

1
2
3
4
5
$ php artisan make:model Page -m

   INFO  Model [app/Models/Page.php] created successfully.

   INFO  Migration [database/migrations/2026_01_20_033243_create_pages_table.php] created successfully.

Then I edited the migration to give the pages table the right schema.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public function up(): void
{
    Schema::create('pages', function (Blueprint $table) {
        $table->id();
        $table->foreignId('site_id')->constrained()->onDelete('cascade');
        $table->string('title');
        $table->string('slug');
        $table->text('content');
        $table->timestamps();

        $table->unique(['site_id', 'slug']); 
    });
}

And of course use artisan to run the migration.

1
2
3
4
5
$ php artisan migrate

   INFO  Running migrations.

  2026_01_20_033243_create_pages_table ................................................................... 8.15ms DONE

With that the model is done, and then I could move on to establishing the relationship between sites and pages.

In the Site model I told the site it can have multiple pages.

1
2
3
4
public function pages()
{
    return $this->hasMany(Page::class);
}

And then in the Page model I told it that sites belong to pages.

1
2
3
4
5
6
protected $fillable = ['site_id', 'title', 'slug', 'content'];

public function site()
{
    return $this->belongsTo(Site::class);
}

The next thing I did was make the pages routable.

1
Route::get('/{page_slug}', [App\Http\Controllers\Tenant\SiteController::class, 'showPage']);

Then I edited the Tenant\SiteController logic so it could find these pages.

1
2
3
4
5
6
7
public function showPage($tenant, $page_slug)
{
    $site = app('current_site');
    $page = $site->pages()->where('slug', $page_slug)->firstOrFail();

    return view('tenant.page', compact('site', 'page'));
}

And finally I created a simple view for the page. I first just copied the home.blade.php view and renamed it page.blade.php swapped out the name and description variables for title and content.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{ $page->title }} | {{ $site->name }}</title>
    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="bg-gray-100 font-sans antialiased">

    @php
        $colors = [
            'blue' => 'bg-blue-600 border-blue-800',
            'green' => 'bg-green-600 border-green-800',
            'purple' => 'bg-purple-600 border-purple-800',
        ];
        $themeClasses = $colors[$site->theme_color] ?? $colors['blue'];
    @endphp

    <div class="min-h-screen flex flex-col items-center py-12">
        <div class="max-w-3xl w-full bg-white shadow-xl rounded-lg overflow-hidden border-t-8 {{ $themeClasses }}">
            <div class="p-8">
                <nav class="mb-8">
                    <a href="/" class="text-sm font-semibold text-gray-500 hover:text-gray-800">
                        ← {{ $site->name }} Home
                    </a>
                </nav>

                <h1 class="text-4xl font-extrabold text-gray-900 mb-6">
                    {{ $page->title }}
                </h1>
                
                <div class="prose max-w-none text-lg text-gray-600 leading-relaxed">
                    {!! nl2br(e($page->content)) !!}
                </div>

                <footer class="mt-12 pt-6 border-t border-gray-100 text-sm text-gray-500 italic">
                    Powered by 
                    <a href="{{ 'http://' . config('app.url') . '/register' }}" class="font-semibold hover:text-gray-600 transition">
                        RhizomeCMS
                    </a> | Sprouted by {{ $site->user->name }}
                </footer>
            </div>
        </div>
    </div>
</body>
</html>

Since I haven’t implemented full CRUD functionality in the UI yet I’ll use tinker to insert a page record in the database.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
s$ php artisan tinker
Psy Shell v0.12.18 (PHP 8.4.1 — cli) by Justin Hileman
New PHP manual is available (latest: 3.0.1). Update with `doc --update-manual`
> $site = App\Models\Site::where('slug', 'joes-site')->first();
= App\Models\Site {#6369
    id: 9,
    created_at: "2026-01-20 03:48:16",
    updated_at: "2026-01-20 03:48:16",
    user_id: 1,
    name: "Joes Site",
    slug: "joes-site",
    custom_domain: null,
    description: "This is Joes Site",
    theme_color: "green",
  }

> $site->pages()->create(['title' => 'About Us', 'slug' => 'about', 'content' => 'This is the story of how Joe started his Rhizome.']);
= App\Models\Page {#6863
    title: "About Us",
    slug: "about",
    content: "This is the story of how Joe started his Rhizome.",
    site_id: 9,
    updated_at: "2026-01-20 03:56:32",
    created_at: "2026-01-20 03:56:32",
    id: 1,
  }

After this I tried to go to joes-site.rhizomecms.test:8000/about expecting an about page, but I got the regular Joes Site page.

To try to debug this I added a die and dump above the showPage method in Tenant\SiteController. This should let me see if the method is even getting called.

1
dd("You are trying to view the page: " . $page_slug);

After refreshing the about page I don’t see the dd text so it seems like the route is never getting matched.

Then I checked my routes using route:list

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
$ php artisan route:list

  GET|HEAD  {tenant}.rhizomecms.test/ .................................................... Tenant\SiteController@index
  GET|HEAD  rhizomecms.test/ .........................................................................................
  GET|HEAD  rhizomecms.test/confirm-password ........................................................ password.confirm
  GET|HEAD  rhizomecms.test/dashboard ...................................................................... dashboard
  GET|HEAD  rhizomecms.test/forgot-password ......................................................... password.request
  GET|HEAD  livewire/livewire.js ......................... Livewire\Mechanisms › FrontendAssets@returnJavaScriptAsFile
  GET|HEAD  livewire/livewire.min.js.map ................................... Livewire\Mechanisms › FrontendAssets@maps
  GET|HEAD  livewire/preview-file/{filename} livewire.preview-file › Livewire\Features › FilePreviewController@handle
  POST      livewire/update ...................... livewire.update › Livewire\Mechanisms › HandleRequests@handleUpdate
  POST      livewire/upload-file .............. livewire.upload-file › Livewire\Features › FileUploadController@handle
  GET|HEAD  rhizomecms.test/login .............................................................................. login
  GET|HEAD  rhizomecms.test/profile .......................................................................... profile
  GET|HEAD  rhizomecms.test/register ........................................................................ register
  GET|HEAD  rhizomecms.test/reset-password/{token} .................................................... password.reset
  POST      rhizomecms.test/sites ................................................. sites.store › SiteController@store
  PUT       rhizomecms.test/sites/{site} ........................................ sites.update › SiteController@update
  DELETE    rhizomecms.test/sites/{site} ...................................... sites.destroy › SiteController@destroy
  GET|HEAD  rhizomecms.test/sites/{site}/edit ....................................... sites.edit › SiteController@edit
  GET|HEAD  storage/{path} ............................................................................. storage.local
  GET|HEAD  up .......................................................................................................
  GET|HEAD  rhizomecms.test/verify-email ......................................................... verification.notice
  GET|HEAD  rhizomecms.test/verify-email/{id}/{hash} ................ verification.verify › Auth\VerifyEmailController
  GET|HEAD  {tenant}.rhizomecms.test/{page_slug} ...................................... Tenant\SiteController@showPage

                                                                                                   Showing [23] routes

Everything there looks correct. The last thing I did is check the page view and the home view and somehow I ended up with them being identical. When I was pasting the code block for this post I think I swapped them and ended up with 2 of the home view.

After fixing my page view and refreshing it finally worked!

Joes Site about page

Recap

Today was going really smooth until I decided to take the long way around and cause an error for myself to troubleshoot. The Model, Controller, Route, View workflow is a pretty easy pattern to follow so continuing to work this way should be good.

The commit for todays session is commit fe53035.