Migrating from DatoCMS to FilamentPHP for our new marketing website
DatoCMS is an amazing headless CMS and I’ve used it to power the marketing site of my multi-tenant food ordering app for quite some time. Our marketing website was written in Nuxt and was configurable through DatoCMS.
And while this worked great, DatoCMS cost us over €200/month so in an effort to reduce our monthly tooling costs, I recently migrated away from DatoCMS to Filament - and, let me tell you, I was pretty impressed by the developer experience and the end result.
In my most recent Youtube video I demonstrate how our current setup works, but I'll write down some key takeaways in this blogpost.
The setup
Our current setup involves a Laravel app on the backend, Vue and Tailwind on the front-end and Inertia as the glue in between. Our website is pretty simple and consists of a couple of pre-defined themed TailwindUI blocks. For example, we have a testimonal section, an FAQ section, a hero section, etc.
Our website has 3 major models:
- we have
pages
- we have
blogPosts
- and we have
modules
, which is basically an addon that customers can buy
Our website is available in multiple languages and to achieve that we use Laravel-translatable so we can localise the necessary columns on the models.
To define the application language we use Laravel Localization, which is able to read the language from the URL and set it in the application.
DatoCMS serves images through IMGIX and this allows for on-the-fly manipulations, so to achieve this functionality in my new setup, I deployed a serverless image handler on AWS.
The serverless image handler is able to do on-the-fly transformations on images residing in an S3 bucket. I configured Filament to upload images to this S3 bucket, and in my frontend I could then request images through my cloudfront URL and apply on-the-fly transformations - pretty neat.
Building blocks
DatoCMS allows you to define complex and repeatable structures that can be embedded inside records. This is a very powerful feature and was the most challenging to replicate in Filament.
Using a mix of Blocks and Repeaters, I was able to come pretty close to my setup in DatoCMS. I defined my own ‘Rich Content’ Block that I was able to apply on a Page and Post resource. In the content section of a page or post, I am now able to add any number of blocks in any order I want.
Blog & Modules
The block that is able to render an article section is a bit special because to render it correctly, we need to do a database query to fetch the blog posts. The block itself is pretty simple and allows us to define a maximum number of posts to display per page.
In the controller I loop over all the blocks, and when I encounter a blog section, I do a database query and replace the data with my paginated eloquent resource.
protected function render($page)
{
$pageData = PageResource::make($page)->toArray(request());
$pageData['content'] = collect($page['content'])->map(function($content) {
if ($content['type'] === 'blog_section') {
$posts = Post::orderBy('published_at', 'desc')->paginate($content['data']['per_page']);
$content['data'] = [
'items' => PostResource::collection($posts->items())->toArray(request()),
// We need to duplicate the 'meta' field from the Resource explicity, because it is only contained within the toResponse method, not within the toArray method
'meta' => [
'base_path' => '/' . request()->path(),
'current_page' => $posts->currentPage(),
'first_page_url' => $posts->url(1),
'from' => $posts->firstItem(),
'last_page' => $posts->lastPage(),
'last_page_url' => $posts->url($posts->lastPage()),
'links' => $posts->linkCollection()->toArray(),
'next_page_url' => $posts->nextPageUrl(),
'path' => $posts->path(),
'per_page' => $posts->perPage(),
'prev_page_url' => $posts->previousPageUrl(),
'to' => $posts->lastItem(),
'total' => $posts->total(),
]
];
} else if ($content['type'] === 'modules_section') {
$modules = Module::orderBy('sort_order', 'desc')->get();
$content['data'] = [
'items' => ModuleResource::collection($modules)->toArray(request()),
'meta' => [
'base_path' => '/' . request()->path(),
]
];
}
return $content;
})->toArray();
return Inertia::render('Page', [
'page' => $pageData,
]);
}
And for the modules we take a pretty similar approach.
To keep things simple, we assume there can only be 1 paginated resource per page.
Blogposts follow a pretty similar rendering pattern as pages, whereas modules are much simpler, and only allow for a single rich editor.
Pages, blog posts and modules all have configurable SEO fields which are saved on the model itself for convenience. These fields are optional, and Inertia is able to fall back to other values, like title, if necessary.
Optimising for speed
And last but not least - let’s talk about performance. Our website is server-side rendered using inertia SSR, meaning it crawlable and usable without Javascript. Setting this up is pretty straightforward, in the Forge interface you can just click a toggle, or you can run the command artisan inertia:ssr
in something like supervisor.
One quick tip: if you are running multiple sites on the same server, make sure the server port is different for all SSR instances, otherwise your inertia SSR script will not start up.
To keep the website running smooth, I use laravel-responsecache from Spatie, but there’s a big caveat with Inertia.
So as you may know, when first visiting a route, Laravel will render HTML and then your frontend Vue app will take over. Every subsequent navigation, Laravel will only return JSON and the Vue app will take care of the rendering of the page.
This means that every page has 2 representations:
- an HTML representation
- a JSON representation
The default CacheProfile of laravel-responsecache has 2 problems:
- it will not cache responses coming from Inertia
- every route is only able to have 1 cached response, so caching HTML and JSON for the same route is not possible
Luckily, we can create our own InertiaAwareCacheProfile and allow both the HTML and JSON representation to be cached by adding an ‘inertia’ or ‘no-inertia’ suffix.
And finally, whenever we update resources, for example a page, we can invalidate both the JSON and HTML representation by calling forget on both the cached Inertia responses as well as the HTML responses.
ResponseCache::selectCachedItems()->usingSuffix('inertia')->forUrls([$url])->forget();
ResponseCache::selectCachedItems()->usingSuffix('no-inertia')->forUrls([$url])->forget();
Whenever we edit a global setting like a navigation link, we invalidate the entire cache because the navigation is visible on every page. And on every deploy we also clear the entire cache so the latest javascript and CSS is always loaded.
This ensures our marketing website is blazing fast and cached for 99% of all requests.