Filtering posts with tags in Hyde

Overview

HydePHP is an amazing Static Site Generator built on top of Laravel Zero. This site is built using HydePHP.

Out of the box, Hyde supports assigning a single category to each blog post. I wanted to assign multiple categories to each blog post and allow users to filter posts by them. In this article, I'm calling them "tags".

In this article, we explore an approach to assigning one or more tags to blog posts in Hyde, complete with:

  • a label and color assigned to each tag
  • the ability for users to filter a list of posts by one or more tag
  • persisting the selected list of tags in the URL (as query parameters)
  • support for users clicking on a tag from a blog post's page to be taken to a list of matching posts

Use case

Assumptions

You have a working HydePHP 2 project with:

  • NPM and package dependencies installed
  • Blade views published

Tag enum

To start, let's create a string-backed PHP enum for each tag. Create a new file at app/Enums/Tag.php with the contents below.

📁
app/Enums/Tag.php
1<?php
2 
3namespace App\Enums;
4 
5enum Tag: string
6{
7 case LARAVEL = 'laravel';
8 case LIVEWIRE = 'livewire';
9 case FILAMENT = 'filament';
10 case ALPINE = 'alpine';
11 case HYDE = 'hyde';
12 case AWS = 'aws';
13 case GITHUB = 'github';
14 
15 public static function values(): array
16 {
17 return collect(self::cases())->map(fn (self $tag) => $tag->value)->all();
18 }
19}

In the above file, we define each enum case we want to support. We also declare a static function, values, that returns an array of strings containing all possible enum values.

Assigning a label

To assign a label to each enum case, create a public function on the Tag enum named getLabel. This method will not accept any arguments and return a string representing the current enum case's label.

📁
app/Enums/Tag.php
1<?php
2 
3namespace App\Enums;
4 
5enum Tag: string
6{
7 // ... cases and other functions here
8 
9 public function getLabel(): string
10 {
11 return match ($this) {
12 self::LARAVEL => 'Laravel',
13 self::LIVEWIRE => 'Livewire',
14 self::FILAMENT => 'Filament',
15 self::ALPINE => 'Alpine',
16 self::HYDE => 'Hyde',
17 self::AWS => 'AWS',
18 self::GITHUB => 'GitHub',
19 };
20 }
21}

Assigning a color

In the same fashion as before, create a new public function on the Tag enum named getColor. Again, this function will not accept any arguments and return a string. This time the returned string will be the lowercase name of a Tailwind 4 color.

📁
app/Enums/Tag.php
1<?php
2 
3namespace App\Enums;
4 
5enum Tag: string
6{
7 // ... cases and other functions here
8 
9 public function getColor(): string
10 {
11 // must include new colors as source in: resources/assets/app.css
12 return match ($this) {
13 self::LARAVEL => 'red',
14 self::LIVEWIRE => 'blue',
15 self::FILAMENT => 'amber',
16 self::ALPINE => 'green',
17 self::HYDE => 'purple',
18 self::AWS => 'yellow',
19 self::GITHUB => 'gray',
20 };
21 }
22}
Any additional Tailwind color used in an above match case must be included as an inline source in a CSS file for Vite's asset building. Read more about this in the Updating CSS section immediately following this one.

Updating CSS

By default, Vite's build process will intentionally only include Tailwind CSS class names that are used within your project. This is done to minimize the size of the resulting CSS file, helping your site to load more quickly.

However, we'll be using some CSS class names dynamically when displaying our tags; the CSS classes used in some places will be determined at the time of the static site being generated.

To always include specific Tailwind CSS classes, regardless of if they are found present in any of the project's source files, we can safelist the class names.

Add the following CSS near the top of your resources/assets/app.css file; after the Tailwind configuration.

📁
resources/assets/app.css
1@config '../../tailwind.config.js';
2 
3@source inline('bg-{red,green,blue,amber,purple,yellow,gray}-{50,100}');
4@source inline('border-{red,green,blue,amber,purple,yellow,gray}-700');
5@source inline('text-{red,green,blue,amber,purple,yellow,gray}-700');

Blade components

Badge component

Next let's create a simple Blade component with the file badge.blade.php. The component will accept a color property and a size property. The size property will have a default size of 'md' (medium).

Note the background, border, and text color CSS classes are dynamically defined. This is why we needed to add Tailwind CSS class names to the safelist in the step above.
📁
resources/views/components/elements/badge.blade.php
1@props(['color', 'size' => 'md'])
2@php
3 $badgeClasses = "badge badge-$color";
4 $textSize = $size === 'md' ? 'base' : $size;
5 $textClasses = "text-$textSize text-$color-700";
6 $paddingClasses = $size === 'md' ? 'px-2 py-0.5' : 'px-4 py-1.5';
7 $backgroundClasses = "bg-$color-50";
8 $borderClasses = "rounded-full border border-$color-700";
9@endphp
10<div class="{{ "$badgeClasses $textClasses $paddingClasses $backgroundClasses $borderClasses" }}">
11 {{ $slot }}
12</div>

This component consists of a div (containing all our CSS classes) and a rendering of the $slot variable. Learn more about props here and slots here.

Tags list component

Let's also create Blade component responsible for displaying a list of tags underneath a post title. This component will be used when listing posts as well as when displaying an individual post.

The component will accept two properties: tags (an array of strings) and asLink (a boolean denoting if each tag should be rendered as an anchor element). We'll set false as the default for asLink.

Note that this blade template uses the badge component we created in the step above.

📁
resources/views/components/post/tags.blade.php
1@props(['tags', 'asLink' => false])
2<div class="flex flex-wrap gap-2 mb-2">
3 @foreach(collect($tags)->map(fn (string $t) => \App\Enums\Tag::from($t)) as $tag)
4 @if($asLink)
5 <a href="/?tag={{ $tag->value }}" class="badge-link">
6 @endif
7 <x-elements.badge :color="$tag->getColor()">
8 {{ $tag->getLabel() }}
9 </x-elements.badge>
10 @if($asLink)
11 </a>
12 @endif
13 @endforeach
14</div>

The root element in the component is a div with CSS classes for display (as flexbox), wrapping flex items if the content width is too wide, a gap between each flex item, and a bottom margin for the element.

Within the root div element, we loop through each tag (turned into a Collection and mapped to instances of our Tag enum) and render our badge component. The color property on the badge component is set to the tag's color, and the slot passed to the badge component is the tag's label.

If the asLink property evaluates to true, an a (anchor) element wraps the badge component. This optional anchor element uses Alpine.js to set the window.location.href upon the element being clicked. The element also contains CSS classes to show a pointer icon as the cursor as well as the badge-link class, which we'll define next.

CSS for the Blade components

To make our site more interactive for the user, we'll use some custom CSS in resources/assets/app.css.

📁
resources/assets/app.css
1/* ... other css */
2 
3.badge-link:hover .badge {
4 @apply bg-white;
5}
6.badge-link:hover .badge,
7.badge-link.badge-link-active .badge {
8 @apply outline outline-current;
9}

The above CSS will:

  • apply white as the background color of a badge when hovering over a badge contained by a link
  • apply an outline of the current color when hovering over a badge contained by a link or when a badge link is denoted as "active"

Alpine data function

We need some JavaScript logic to facilitate the interactivity of our menu of tags the user can select from.

We'll define an Alpine.js data function in our resources/assets/app.js file. This data function will return the definition of an Alpine.js data object which will be bound to x-data on our main content element.

Add the following to your app.js file:

📁
resources/assets/app.js
1document.addEventListener('alpine:init', () => {
2 Alpine.data('tagsMenu', (options) => ({
3 selectedTags: [],
4 init() {
5 this.selectedTags = (new URLSearchParams(window.location.search))
6 .getAll('tag')
7 .filter((tag) => options.tags.includes(tag));
8 },
9 toggleTag(tag) {
10 this.selectedTags = this.selectedTags.includes(tag)
11 ? this.selectedTags.filter((t) => t !== tag)
12 : [...this.selectedTags, tag];
13 
14 const query = !!this.selectedTags.length
15 ? new URLSearchParams(this.selectedTags.map((t) => ['tag', t]))
16 : null;
17 
18 window.history.replaceState(null, null, query ? `/?${query.toString()}` : '/');
19 },
20 }))
21});

In the above JavaScript, we add an event listener on the global document object that listens for the alpine:init lifecycle hook. When the hook fires, we'll register Alpine data under the name tagsMenu. In the callback (second argument), we accept an options argument that will be used when resolving the data object.

As for the data object itself:

  • the selectedTags property will contain an array of strings representing which tags are currently selected
  • the init function is called by Alpine when the component is first initialized
    • we're using the function to set the selectedTags property based on the URL's query parameters
  • the toggleTag function accepts a tag argument and is responsible for selecting or deselecting a specific tag
    • the function also uses the History API to replace the current state with query parameters for the currently selected tags

Tag selection menu

With our Alpine.js data in place, we can now use it within a Blade template.

This article assumes you are listing all posts on the index (home) page, located at _pages/index.blade.php.

Alpine.js component

In _pages/index.blade.php (or wherever you are listing posts), add an x-data attribute to the main element (or a div element containing your tag menu and list of posts).

📁
_pages/index.blade.php
1<main id="content" class="mx-auto max-w-7xl py-12 px-8"
2 x-data="tagsMenu({
3 tags: @js(\App\Enums\Tag::values())
4 })"
5>
6 <!-- ... other HTML -->
7</main>

Note that we're using the @js Blade directive to render the array of possible tag values as JSON. We use this JSON array as the value of the tags property in the object we're passing to the callback argument to the tagsMenu function. This tells our Alpine.js component logic which tags are valid for selection.

Rendering menu items

Within the main (or div) element that will contain your tags menu and list of posts, you can render the tags menu as shown below.

📁
_pages/index.blade.php
1<main id="content" class="mx-auto max-w-7xl py-12 px-8"
2 x-data="tagsMenu({
3 tags: @js(\App\Enums\Tag::values())
4 })"
5>
6 <!-- ... other HTML -->
7 
8 <div class="mt-4 flex flex-wrap gap-2 md:justify-center">
9 @foreach(\App\Enums\Tag::cases() as $tag)
10 <button @click="toggleTag('{{ $tag->value}}')"
11 :class="{
12 'badge-link rounded-full cursor-pointer': true,
13 'badge-link-active': selectedTags.includes('{{ $tag->value }}')
14 }">
15 <x-elements.badge size="xl" :color="$tag->getColor()">
16 {{ $tag->getLabel() }}
17 </x-elements.badge>
18 </button>
19 @endforeach
20 </div>
21 
22 <!-- ... other HTML -->
23</main>

Here we use a flexbox, with wrapping of items and a gap between items, to contain a button element for each available tag (as defined by the cases in our Tag enum).

Each button uses the click event handler to call the toggleTag function with an argument of a string representing which tag has been clicked on. Each button also has a binding to the class attribute (using class object syntax) to conditionally include or exclude the badge-link-active CSS class.

Within the slot for each button, we render our badge Blade component with an xl (extra large) size and the tag's color passed as the color property. The badge component's slot contains the label for the relevant tag.

Conditionally displaying posts

Find in your project where MarkdownPost::getLatestPosts() is called in a Blade template. By default, after publishing views, this will be at resources/views/vendor/hyde/components/blog-post-feed.blade.php.

Within the foreach loop that loops through posts, a li (list item) element is rendered. We need to add attributes to this element to control if the list item is displayed or hidden.

1<ol itemscope itemtype="https://schema.org/ItemList">
2 @foreach($posts ?? MarkdownPost::getLatestPosts() as $post)
3 <li x-show="!selectedTags.length || @js($post->matter('tags', [])).some((tag) => selectedTags.includes(tag))"
4 itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem" class="mt-4 mb-8">
5 <meta itemprop="position" content="{{ $loop->iteration }}">
6 @include('hyde::components.article-excerpt')
7 </li>
8 @endforeach
9</ol>

The x-show directive is being used to conditionally display the list item when one of the following conditions are true:

  • there are no tags selected
  • the tags assigned to the post overlap the currently selected tags from the tags menu

We are retrieving the tags assigned to the post with the matter method on the MarkdownPost class (available via the InteractsWithFrontMatter trait on a parent class). If no tags are assigned, the second argument to the matter method will be returned (in this case, an empty array).

Listing tags underneath posts

To display tags underneath posts, we can use the components.post.tags Blade component we created in a previous step.

In a list of posts

Within a list of posts, I want tag badges to have no interaction (instead of loading a list of posts filtered by the tag that was clicked on). I also only want to include the rendered components.post.tags Blade component when there is at least one tag associated with the post.

To accomplish this, we can use the @includeWhen Blade directive. Within the @foreach(...) loop that renders your list of posts, add the following snippet in the location you'd like the rendered list of tags to display. By default, this is somewhere within the Blade template: resources/views/vendor/hyde/components/article-excerpt.blade.php.

📁
resources/views/vendor/hyde/components/article-excerpt.blade.php
1<footer>
2 <div class="mt-2">
3 @includeWhen(!empty($post->matter('tags')), 'components.post.tags', ['tags' => $post->matter('tags')])
4 </div>
5 <a href="{{ $post->getRoute() }}" class="text-indigo-500 hover:underline font-medium">
6 Read post
7 </a>
8</footer>

Note that we are omitting the asLink property, so it is defaulting to a value of false.

On the post's page

On the dedicated page for each post, I want tag badges to be interactive. When a user clicks on a tag, they should be taken to a page listing posts that have been assigned that tag.

We can again use the @includeWhen Blade directive to accomplish this. However, this time we will pass true as the value of the asLink property.

This time we'll update the resources/views/vendor/hyde/components/post/article.blade.php file to change the header that shows on the page dedicated to each post.

📁
resources/views/vendor/hyde/components/post/article.blade.php
1<header aria-label="Header section" role="doc-pageheader">
2 <h1 itemprop="headline" class="mb-4">{{ $page->title }}</h1>
3 <div id="byline" aria-label="About the post" role="doc-introduction">
4 @includeWhen(!empty($page->matter('tags')), 'components.post.tags', [
5 'tags' => $page->matter->get('tags'),
6 'asLink' => true,
7 ])
8 @includeWhen(isset($page->date), 'hyde::components.post.date')
9 @includeWhen(isset($page->author), 'hyde::components.post.author')
10 </div>
11</header>

Serving and building your site

You can serve your site locally to preview your changes with the following command:

1php hyde serve --vite

To build your site, run the following commands:

1npm run build
2php hyde build --vite

Conclusion

This article summarizes how this site, built using HydePHP and Alpine.js, achieves assigning one or more tags to each post. Posts can be filtered by selecting tags, which persists in the URL as query parameters.

🤖
Did you spot a mistake in this article? Have a suggestion for how something can be improved? Even if you'd just like to comment or chat about something else, I'd love to hear from you! Contact me.

Syntax highlighting by Torchlight.dev

End of article