Migrating from Jigsaw to Laravel

• 9 min read

In early August I've deployed a slightly new design of this website and with it, I've changed how the website is generated. A behind the scenes look on what has changed and why.

In 2019 I switched from Kirby to Jigsaw.

Jigsaw is a static site generator written in PHP that uses Blade as its template language. It's great! (I still love and use Jigsaw at work in 20+ projects.)
However, over the years I added more and more features to this site that just didn't neatly fit into Jigsaw:

  • Replaced the default Markdown parser with the league/commonmark-parser and added community- and my own extensions.
  • Fetch and store webmentions and associate them with posts.
  • Server side syntax highlighting by reverse engineering the Torchlight Jigsaw package, as it didn't work with my Markdown parser.

It got messy.

But personal websites are playgrounds. A (mostly) riskfree place to try new ideas and technologies. When I added the features mentioned above, I knew that I deviated from the given path and that I pay a price for it. That things get more complicated or that build time would go up.

And how the build time has gone up.
My wacky server-side-syntax highlithing-solution increased the build time to 3-4 seconds. Not that long, but noticable when working on the design, debugging an error or when adding the finishing touches on a blog post.

When Ryan Chandler released Orbit in 2021 I knew that – in combination with Laravel – this was my future blog stack.

I wrote down a note about "Rewriting stefanzweifel.dev with Laravel & Orbit" and let it linger.

The new stack #

All started with laravel new stefanzweifel.dev to get a fresh Laravel 9 app.

The mentioned problem of build time was solved by serving the site with Valet on my local machine. Building the site to a static site is only done when deploying to production. Thus eliminating any wait time.

I installed Orbit and created the appropriate models for my site: Page, Post, Book and Project. Here's the Orbit database schema for Post.

public static function schema(Blueprint $table)
{
    $table->string('title')->nullable();
    $table->string('slug')->nullable();
    $table->text('content');
    $table->text('excerpt')->nullable();
    $table->timestamp('published_at')->nullable();
    $table->boolean('featured')->default(false);
    $table->boolean('show_old_post_warning')->default(true);

    $table->json('changelog')->nullable();
    $table->string('open_graph_image')->nullable();
}

Orbit stores content in plain text files in the content-folder. Migrating my existing content from Jigsaw to Orbit was as easy as dragging my existing blog posts to their new location and then updating the meta data in Markdown frontmatter for each blog post. (This took me maybe 1-2 hours.)

Next up was routing. Jigsaw has collections which generates the routes for me. In a Laravel app, I have to do this myself. Here are the registered routes in my routes/web.php-file.

Route::get('/', HomepageController::class)->name('home');
Route::get('/posts/', [PostController::class, 'index'])->name('posts.index');
Route::get('/posts/{year}/{month}/{day}/{post:slug}', [PostController::class, 'show'])->name('posts.show');
Route::get('/reading', ReadingController::class)->name('books.index');
Route::get('/projects', ProjectController::class)->name('projects.index');

Route::get('/404', function () {
    seo()->title('Page not found');
    seo()->description('');
    seo()->image('');

    return view('errors.404');
});

Route::fallback(FallbackPageController::class);

Straightforward as you would expect. Dedicated controllers for homepage, posts and projects pass data to Blade views and render them.
The FallbackPageController is triggered when a route does not exist. Here's the code for the controller.

<?php

namespace App\Http\Controllers;

use App\Models\Page;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class FallbackPageController extends Controller
{
    public function __invoke(Request $request)
    {
        $page = Page::query()
            ->where('is_public', true)
            ->where('slug', $request->path())
            ->firstOrFail();

        seo()
            ->title($page->title)
            ->description($page->meta_description)
            ->image(Storage::url($page->open_graph_image));

        return view('page', [
            'page' => $page,
        ]);
    }
}

The app checks if I've created a Page-model for the route. If no Page exists, a 404 error is thrown. Otherwise a very simple Blade view is rendered.


To manage the content on the site, I've added Filament. With Filament I could quickly build an admin panel that lists all my posts and projects and allows me to add new posts or edit existing ones. (I don't write blog posts in Filament thought; but it's great for fixing typos or making last minute edits.)


For sitemap, RSS-feed and SEO stuff I use the usual suspects packages:

While working on the site locally, these packages have done their job fine. But when I started adding the "export" command that turns the site into a static site, some cracks started form.

The sitemap package crawls my app and generates the sitemap based on the found URLs. Works great if you run a Laravel app in production, but you run into problems when the crawled URL is not the final production URL. (eg. when the app crawls https://staging.example.com, all links in the sitemap point to https://staging.example.com instead of example.com)

Right now I manually generate the sitemap on my local machine with the following Artisan command.

Artisan::command('sitemap:generate', function () {
    SitemapGenerator::create(config('app.url'))
        ->writeToFile(public_path('sitemap.xml'));

    $sitemapContent = File::get(public_path('sitemap.xml'));
    $sitemapContent = Str::replace(config('app.url'), 'https://stefanzweifel.dev', $sitemapContent);

    File::put(public_path('sitemap.xml'), $sitemapContent);
});

I let the package do it's thing, read the generated sitemap.xml, replace all instances of my local URL with https://stefanzweifel.dev and write the changes to sitemap.xml.
Not ideal, but it gets the job done. Definitely a part of this project I want to improve soon, so I don't have to remember running this command everytime a new blog post is added.

The next problem is not directly an issue with Spatie's RSS package, but something that all RSS packages seem not to handle: relative URLs.
You see, images added to my blog posts are referenced by relative URLs (/assets/images/earth.jpg) instead of absolute URLs (http://localhost/assets/images/earth.jpg).

Therefore, when building the RSS feed for the site, images in the feed body have relative URLs as well. When pushed to production, RSS readers will display broken images for the feed. The domain is missing so the reader tries to render /assets/images/earth.jpg from its own domain.

To solve this, I naively search the src="" attribute in the content of the RSS feed item and prepend my domain.

public function toFeedItem(): FeedItem
{
    // Fix paths to images
    $content = app(MarkdownRenderer::class)->toHtml($this->content);
    $content = str_replace(
        "src=\"/assets/images/",
        "src=\"https://stefanzweifel.dev/assets/images/",
        $content
    );

    return FeedItem::create()
        ->id($this->getUrl())
        ->title($this->title)
        ->summary($content)
        ->updated($this->published_at)
        ->link($this->getUrl())
        ->authorName('Stefan Zweifel');
}

The last piece of the puzzle is generating a static site bundle from the site. For this I'm using spatie/laravel-export as I was using it in the past.
The package itself is great, but I ran into issues with my deployment setup which I naively solved – for now – with a bunch of Bash scripts:

When the package generates the static site bundle, it crawls your local app. Each resolved URL is converted to an HTML file and stored on disk.

With the redesign, I aligned the permalinks of all blog posts prior to 2019 to my current structure. I added redirect rules and all.
When the package now crawls my site and finds a link with the old permalink structure, it breaks (correctly): It can't create a HTML file for a 301 Moved Permanently response.[1]
I solved this problem by updating all cross-reference links in my old blog posts to use the new permalinks.

The next issue concerned the APP_URL value while creating the export.

I host this website on Vercel. I created my own GitHub Actions workflow to deploy pull request previews and to deploy to production.

To save CI minutes, the site is only exported to a static site bundle once.
When a pull request preview is deployed, the APP_URL can't be https://stefanzweifel.dev, as this would use the CSS from production and all links using the route()-helper would point to https://stefanzweifel.dev/ instead of https://{subdomain}.vercel.app/. 🙃

For now, I've updated my GitHub Actions workflow to build the site using APP_URL=http://unique-localhost and then replacing the string everywhere with a sed-command.
Here's the step from my updated workflow file.

-   name: Replace Placeholder Domain with correct domain
    run: |
      find dist -type f -name "*.html" -print0 | xargs -0 sed -i "s#http://unique-localhost#https://stefanzweifel.dev#g"
      find dist -type f -name "*.xml" -print0 | xargs -0 sed -i "s#http://unique-localhost#https://stefanzweifel.dev#g"

I want to note here: these aren't necessarily problems of the packages, but rather how I use them.


To make this post not longer than it already is, here is the complete list of the packages installed on my site:

  • archtechx/laravel-seo
  • blade-ui-kit/blade-heroicons
  • filament/filament
  • ryangjchandler/orbit
  • spatie/browsershot
  • spatie/laravel-export
  • spatie/laravel-feed
  • spatie/laravel-markdown
  • spatie/laravel-missing-page-redirector
  • spatie/laravel-sitemap
  • wnx/commonmark-mark-extension

Conclusion #

At the beginning of this article I mentioned that my Jigsaw setup has gone haywire, has become too complicated and slow.

The base of my new setup is much simpler: Plain Laravel with Orbit as an Eloquent driver. Access to Laravel packages. No build time.

In comparison with the Jigsaw setup, the generation of the static site bundle and the deployment has become more complicated.

To be honest: I didn't expect this to become such a problem. I thought once I figure out how Orbit works, it's all smooth sailing.

But when I started working on the export/deployment part of the project, I ran into issue after issue. The fear of the sunk cost fallacy kicked in and I pushed through – instead of taking a few minutes and think about, if this is really the right solution.

Would I do it again? Not sure. It showed me how complex CMSs truly are and how much work Jigsaw and Statamic take off of you.

For now, I keep this setup.
I hope I can fix all the remaining issues in the static site generation/deployment part. Maybe I can even extract a package or two out of it.


  1. Yes it could create a http-equiv meta tag, but that feels icky. ↩︎