Showing your Hashnode blog posts on your Laravel blog using Hashnode Public APIs
Table of contents
- What is Hashnode public API?
- Why use headless Hashnode?
- Hashnode's GraphQL API
- Using GraphQL with Laravel
- Installing Laravel
- Making a BlogController
- Create the view:
- Cleaning up the controller
- Designing the blog
- Using layouts
- Working with tags
- Implement pagination
- Viewing a post
- Setting up a test environment using Pest
- Conclusion
In this post, I'm going to show you how to build a Laravel blog and pull in blog posts from a Hashnode blog using its public API.
All of the API endpoints are public, no authentication will be required.
What is Hashnode public API?
The Hashnode Public API is a GraphQL API that allows access to data from Hashnode's servers. Using these endpoints, information can be read such as blog posts, comments, and user details. For our purposes, this post will focus on fetching blog post data.
Why use headless Hashnode?
With headless Hashnode, you can build a fully bespoke design on your domain whilst still using Hashnode's blogging platform as your backend for managing your posts and pages.
Hashnode's GraphQL API
To consume Hashnode's public API you will need to use a client that can read GraphQL. Hashnode has a GraphQL playground you can use to try out its API right away by going to https://gql.hashnode.com and trying the examples at https://apidocs.hashnode.com/#introduction-item-9
All GraphQL API calls are performed by sending a POST request to https://gql.hashnode.com and a query in the body.
Using GraphQL with Laravel
Before we get into the weeds of building let's figure out how we can consume GraphQL with Laravel.
Several tools can be used listed on https://graphql.org/code/#php
Since we are using Laravel we don't need to install any third-party tools, instead, we can use Laravel's built-in HTTP Client https://laravel.com/docs/10.x/http-client
This allows us to run GraphQL queries, here's a quick example.
$response = Http::post("https://gql.hashnode.com", [
'query' => 'query Publication {
publication(host: "dcblog.dev") {
author {
followersCount
}
}
}'
]);
This sends a POST request to https://gql.hashnode.com and requests a publication (in this example from dcblog.dev an existing Hashnode blog.) Get the number of followers from the blog author.
The HTTP client returns a response we can then convert into JSON using the JSON method.
$response->json()
From here we can extract the data from the response:
$followers = $response->json()['data']['publication']['author']['followersCount'];
Installing Laravel
To install a fresh copy of Laravel using Composer, use the following command in a terminal.
This will create a new Laravel project called hashblog. You can name this anything you like.
composer create-project laravel/laravel hashblog
Defining a Route for the Posts
Open routes/web.php
to create our first route
Delete the default route:
Route::get('/', function () {
return view('welcome');
});
Create a route that responds to /
which loads a controller called BlogController
and loads a method called index
use App\Http\Controllers\BlogController;
use Illuminate\Support\Facades\Route;
Route::get('/', [BlogController::class, 'index']);
Making a BlogController
Create the controller with artisan, in a terminal type:
php artisan make:controller BlogController
This will create app/Http/Controllers/BlogController.php
Delete the use statement, we won't be needing it.
use Illuminate\Http\Request;
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class BlogController extends Controller
{
//
}
Create a method called index, that returns a response using the HTTP::client
copy and paste the example from the Hashnode API docs to fetch posts https://apidocs.hashnode.com/#introduction-item-9
<?php
namespace App\Http\Controllers;
use Illuminate\Support\Facades\Http;
class BlogController extends Controller
{
public function index()
{
return Http::post("https://gql.hashnode.com", [
'query' => 'query Publication {
publication(host: "blog.developerdao.com") {
isTeam
title
posts(first: 10) {
edges {
node {
title
brief
url
}
}
}
}
}'
]);
}
}
This will output the JSON response from the API:
{
"data": {
"publication": {
"isTeam": true,
"title": "Developer DAO Blog | Web3 Tutorials",
"posts": {
"edges": [
{
"node": {
"title": "Unleash the Power of DeFi on Bitcoin",
"brief": "If you've been building on Ethereum and EVM-compatible chains and yet are intrigued by the potential of Bitcoin, this post is for you. We will talk about Stacks, a Bitcoin layer 2, and how it will unlock $500B+ of Bitcoin liquidity for everyday apps....",
"url": "https://blog.developerdao.com/unleash-the-power-of-defi-on-bitcoin"
}
},
]
}
}
}
}
To reference any of the keys inside this array is a case of assigning the response to a variable and then calling a JSON method:
$response = Http::post(...)
$response->json()
To get the posts out, skip the first few elements:
$response->json()['data']['publication']['posts'];
This would then return the contents of the post's key.
Let's look at the query a little closer:
query Publication {
publication(host: "blog.developerdao.com") {
isTeam
title
posts(first: 10) {
edges {
node {
title
brief
url
}
}
}
}
}
The query always starts with the word query
followed by the type of query. Next, we set publication() and set the host. The host is the Hashnode blog to read from.
Inside the brackets set the fields that you want to be returned in this case isTeam
and title
are used which returns a bool for if this blog is a team or not and its title.
Next, we're using posts(first: 10)
to call its posts and pull the first 10 records out. Next inside the edges and node we set which fields we want for the post data.
To see all field options for a post see the Hashnode API docs https://apidocs.hashnode.com/#definition-Post
The fields I'll be using for posts are as follows:
title
slug
brief
readTimeInMinutes
publishedAt
views
url
coverImage {
url
}
tags {
name
slug
}
author {
name
username
profilePicture
}
The update query now looks like this: (I've switched the host to my blog)
I've dropped isTeam
and title
from the publication as well, we won't be needing them.
In addition, we only want to return the posts so we set all the keys to get to the posts using
$response->json()['data']['publication']['posts']['edges']
$response = Http::post("https://gql.hashnode.com", [
'query' => 'query Publication {
publication(host: "dcblog.dev") {
posts(first: 10) {
edges {
node {
title
slug
brief
readTimeInMinutes
publishedAt
views
url
coverImage {
url
}
tags {
name
slug
}
author {
name
username
profilePicture
}
}
}
}
}
}'
]);
return view('blog.index', [
'posts' => $response->json()['data']['publication']['posts']['edges']
]);
To pass the posts to a view, assign the data to an array and pass it to a view called blog/index.blade.index
as above.
The index method now looks like this:
public function index()
{
$response = Http::post("https://gql.hashnode.com", [
'query' => 'query Publication {
publication(host: "dcblog.dev") {
isTeam
title
posts(first: 10) {
edges {
node {
title
slug
brief
readTimeInMinutes
publishedAt
views
url
coverImage {
url
}
tags {
name
slug
}
author {
name
username
profilePicture
}
}
}
}
}
}'
]);
return view('blog.index', [
'posts' => $response->json()['data']['publication']['posts']['edges']
]);
}
Create the view:
php artisan make:view blog.index
This will create a folder called blog
inside resources/views
and a view called index.blade.php
.
Inside the index file enter:
@foreach($posts as $post)
@php $post = $post['node']; @endphp
<h2>{{ $post['title'] }}</h2>
<div>
{!! Str::limit($post['brief'], 100) !!}
</div>
<a href="{{ url($post['slug']) }}">Read More</a>
<hr>
@endforeach
This is enough to see the results in a browser without any styling of any kind. This is not using all the fields either, we will add those in shortly.
Viewing the project in a browser will look like the below image.
It's not pretty but it's working!
Cleaning up the controller
Whilst our code is working, to me there's too much happening inside the controller. It would be better if the controller could load another class that returns the posts and the controller loads them like this:
return view('blog.index', [
'posts' => $hashnodeService->getPosts()['edges']
]);
Let's do some refactoring to extract the Hashnode API lookup to a dedicated class.
Let's make a folder called Services
inside the app
folder. Create a new file inside the services folder called HashnodeService.php
Create the following class:
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
class HashnodeService
{
protected string $host;
protected string $url;
public function __construct()
{
$this->host = config('services.hashnode.host');
$this->url = config('services.hashnode.url');
}
public function getPosts(): array
{
//get the posts
}
}
Inside the __construct
we assign values to host
and url
These will come from a config file.
The host
is the Hashnode blog and the url
is the API endpoint we post to.
Rather than hard-coding these, we move them to a configuration file.
Hashnode service keys
Open config/services.php
Add an array of keys:
'hashnode' => [
'host' => env('HASHNODE_HOST'),
'url' => env('HASHNODE_URL', 'https://gql.hashnode.com/')
],
This allows the host to be read from your .env
file. For the url we will hard code the API path, this can also be set in your .env
but when not set the default path will be picked up automatically.
Inside your .env
file set a value of the host to read from.
With this setup, you can switch which Hashnode blog is read from a single place.
HASHNODE_HOST='dcblog.dev'
Adding methods to HashnodeService
Switch back to the HashnodeService class. Next, create a method for each type of query for now we only have one called getPosts()
. This will return an array of posts.
Move the query from the BlogController's index method to this method:
public function getPosts(): array
{
$response = Http::post($this->url, [
'query' => 'query Publication {
publication(host: "' . $this->host . '") {
posts(first: 10) {
edges {
node {
title
slug
brief
readTimeInMinutes
publishedAt
views
url
coverImage {
url
}
tags {
name
slug
}
author {
name
username
profilePicture
}
}
}
}
}
}'
]);
$publication = $response->json()['data']['publication'];
if ($publication === null) {
abort(400, 'Hashnode host not found');
}
return $publication['posts'];
}
This method now returns a list of posts from the Hashnode blog and if the blog cannot be found we fire off a 400 bad request event. Otherwise, the array of posts is returned.
Next, we need to call this class from our controller using dependency injection. This means in the index method we specify the class by its name and then define a variable reference. Laravel will automatically create an instance of this class for us.
Remember to import the class in a use statement.
<?php
namespace App\Http\Controllers;
use App\Services\HashnodeService;
class BlogController extends Controller
{
public function index(HashnodeService $hashnodeService)
{
return view('blog.index', [
'posts' => $hashnodeService->getPosts()['edges']
]);
}
}
With this in place, our controller is now much cleaner and is only responsible for passing the posts of the views, the HashnodeService class will load the posts.
Designing the blog
Let's revisit the front end. Right now our view page has no styling at all. The first step will be to install Tailwind which is my CSS framework of choice, feel free to any something else.
Installing TailwindCSS
Install Tailwind by entering the following in a terminal:
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
npm install -D @tailwindcss/typography
This will install Tailwind and update the package.json
file. Also, a tailwind.config.js
file will be created.
Open tailwind.config.js
and paste in the following:
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./resources/**/*.blade.php",
"./resources/**/*.js",
"./resources/**/*.vue",
],
theme: {
extend: {},
},
plugins: [
require('@tailwindcss/typography'),
],
}
Next open resources/css/app.css
and paste in:
@tailwind base;
@tailwind components;
@tailwind utilities;
To compile Tailwind at any time run the build command:
npm run build
Using layouts
Let's create a layout file and set up a foundation for styling the blog.
Inside the resources/views
folder create another folder called layouts
and create a file inside called app.blade.php
This will be the main layout file for our blog.
@yield
is used to create placeholders that our views can inject code into.
In this layout, we have placeholders for title, meta, and content.
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>@yield('title') - {{ config('app.name') }}</title>
<link rel="canonical" href='{{ url()->current() }}'>
@vite(['resources/css/app.css', 'resources/js/app.js'])
@yield('meta')
</head>
<body class="bg-gray-900 text-white">
<div class="container px-8 mx-auto">
@yield('content')
</div>
</body>
</html>
Now open up blog/index.blade.php
.
Update this to use the layout and set the title and content section:
@extends('layouts.app')
@section('title', 'Blog')
@section('content')
@foreach($posts as $post)
@php $post = $post['node']; @endphp
<h2>{{ $post['title'] }}</h2>
<div>
{!! Str::limit($post['brief'], 100) !!}
</div>
<a href="{{ url($post['slug']) }}">Read More</a>
<hr>
@endforeach
@endsection
Looking at the browser the application now looks like this:
Still looking terrible but we can see the classes used in the layout file are taking effect. Go back to the layout file and let's add a simple navbar.
Since we're in "design mode" meaning we're adding new tailwind classes, it's best to run npm run dev
so the Vite will recompile our CSS file as we make changes.
Inside the body of the layout file add:
<nav class="bg-indigo-800 shadow py-2 mb-10">
<div class="max-w-7xl mx-auto px-2 sm:px-4 lg:px-8">
<div class="flex-shrink py-2">
<div class="flex px-2 lg:px-0">
<a href='{{ url('/') }}'>
<div class="flex items-center">
<div class="block px-1 pt-1 text-sm font-medium leading-5 text-white hover:text-gray-300">
<div>{{ config('app.name') }}</div>
</div>
</div>
</a>
<div class="flex flex-grow justify-between">
<div class="lg:ml-6 lg:flex">
<a href="/" class="ml-8 inline-flex items-center px-1 pt-1 text-sm text-white hover:text-gray-300">Blog</a>
</div>
</div>
</div>
</div>
</div>
</nav>
Now we have a header that looks like this:
Next, we will tackle the blog posts, but first, let's do a bit of cleanup.
Create a new blade file called posts.blade.php
inside the blog
folder and cut and paste the foreach loop from index.blade.php
into the posts.blade.php
file
Inside index.blade.php add:
@include('blog.posts', ['posts' => $posts])
This will load the posts, the reason for this is you may want to load posts for different files later on such as a tags page or a search page. It's better to have the posts in its own file so it can be included in other files as needed.
@extends('layouts.app')
@section('title', 'Blog')
@section('content')
@include('blog.posts', ['posts' => $posts])
@endsection
Now open posts.blade.php
we are ready to start styling the page and add more details to the posts.
Right now posts.blade.php
looks like this:
@foreach($posts as $post)
@php $post = $post['node']; @endphp
<h2>{{ $post['title'] }}</h2>
<div>
{!! Str::limit($post['brief'], 100) !!}
</div>
<a href="{{ url($post['slug']) }}">Read More</a>
<hr>
@endforeach
The first thing we want is a grid for the posts to go into, before the @foreach
add:
<div class="grid gap-5 max-w-lg mx-auto lg:grid-cols-3 lg:max-w-none">
@foreach($posts as $post)
...
@endforeach
</div>
The page now has a three-column grid:
Next, add a div around the post content:
<div class="flex-1 bg-indigo-800 p-6 flex flex-col justify-between">
...post content
</div>
Now let's loop over and display any tags that exist, this will link to a URL of /tags/{tag} the route will be created later.
<p class="text-sm leading-5 font-medium text-primary mb-0">
@foreach($post['tags'] as $tag)
<a href="{{ url('tag/'.strtolower($tag['slug'])) }}">
{{ $tag['name'] }}
@if (!$loop->last) | @endif
</a>
@endforeach
</p>
Now style the title and brief:
<a href="{{ url($post['slug']) }}" class="block">
<h2 class="mt-2 text-xl leading-7 font-semibold">
{{ $post['title'] }}
</h2>
<div class="mt-3 text-base leading-6 text-white">
{!! Str::limit($post['brief'], 100) !!}
</div>
</a>
Now we want to show the author details and link the author back to their Hashnode profile.
<div class="mt-6 flex items-center">
<div class="flex-shrink-0">
@if (!empty($post['author']['profilePicture']))
<a href='{{ 'https://hashnode.com/@'.$post['author']['username'] }}'>
<img class="w-10 h-10 pr-1 rounded-full" src="{{ $post['author']['profilePicture'] }}" alt="{{ $post['author']['name'] }}">
</a>
@endif
</div>
<div class="ml-3">
<p class="text-sm mb-0 font-medium text-primary">
<a href="{{ 'https://hashnode.com/@'.$post['author']['username'] }}" class="hover:underline">{{ $post['author']['name'] }}</a>
</p>
<div class="flex text-sm text-gray-200 dark:text-gray-200">
<p>
{{ $post['readTimeInMinutes']}} min read -
{{ $post['views'] }} views -
<time datetime="{{ $post['publishedAt'] }}">{{ date('jS M, Y', strtotime($post['publishedAt'])) }}</time>
</p>
</div>
</div>
</div>
The Blog is looking much better, the final step is to add the post Image should it exist.
@if(!empty($post['coverImage']['url']))
<div class="flex-shrink-0">
<a href="{{ url($post['slug']) }}" class="block">
<img class="w-full object-cover rounded-t-lg" src="{{ $post['coverImage']['url'] }}" alt="">
</a>
</div>
@endif
Putting it all together:
<div class="grid gap-5 max-w-lg mx-auto lg:grid-cols-3 lg:max-w-none">
@foreach($posts as $post)
@php $post = $post['node']; @endphp
<div class="flex flex-col rounded-lg shadow-lg overflow-hidden mb-10">
@if(!empty($post['coverImage']['url']))
<div class="flex-shrink-0">
<a href="{{ url($post['slug']) }}" class="block">
<img class="w-full object-cover rounded-t-lg" src="{{ $post['coverImage']['url'] }}" alt="">
</a>
</div>
@endif
<div class="flex-1 bg-indigo-800 p-6 flex flex-col justify-between">
<div class="flex-1">
<p class="text-sm leading-5 font-medium text-primary mb-0">
@foreach($post['tags'] as $tag)
<a href="{{ url('tag/'.strtolower($tag['slug'])) }}">
{{ $tag['name'] }}
@if (!$loop->last) | @endif
</a>
@endforeach
</p>
<a href="{{ url($post['slug']) }}" class="block">
<h2 class="mt-2 text-xl leading-7 font-semibold">
{{ $post['title'] }}
</h2>
<div class="mt-3 text-base leading-6 text-white">
{!! Str::limit($post['brief'], 100) !!}
</div>
</a>
</div>
<div class="mt-6 flex items-center">
<div class="flex-shrink-0">
@if (!empty($post['author']['profilePicture']))
<a href='{{ 'https://hashnode.com/@'.$post['author']['username'] }}'>
<img class="w-10 h-10 pr-1 rounded-full" src="{{ $post['author']['profilePicture'] }}" alt="{{ $post['author']['name'] }}">
</a>
@endif
</div>
<div class="ml-3">
<p class="text-sm mb-0 font-medium text-primary">
<a href="{{ 'https://hashnode.com/@'.$post['author']['username'] }}" class="hover:underline">{{ $post['author']['name'] }}</a>
</p>
<div class="flex text-sm text-gray-200 dark:text-gray-200">
<p>
{{ $post['readTimeInMinutes']}} min read -
{{ $post['views'] }} views -
<time datetime="{{ $post['publishedAt'] }}">{{ date('jS M, Y', strtotime($post['publishedAt'])) }}</time>
</p>
</div>
</div>
</div>
</div>
</div>
@endforeach
</div>
Much better.
Working with tags
Now the listing of posts is in place, let's focus on tags, we have tags being displayed in the posts. What we want is to be able to click on a tag and see all posts matching that tag.
Create a new route:
This will respond to the URL /tag/{tag}
ie /tag/laravel
and load a tag method.
Route::get('tag/{tag}', [BlogController::class, 'tag']);
Next, add the controller method:
public function tag(HashnodeService $hashnodeService, $tag)
{
return view('blog.tag', [
'tag' => $tag,
'posts' => $hashnodeService->getPostsByTag($tag)['edges']
]);
}
This loads up a view called tag and passes the tag and the posts. This time the HashnodeService class calls a getPostsByTag($tag)
method.
Next, add a method called getPostsByTag($tag)
:
The method accepts a $tag
. This is the same query as the posts except in the posts method there is a filter:
filter: { tagSlugs: ["'.$tag.'"] }
public function getPostsByTag(string $tag): array
{
$response = Http::post($this->url, [
'query' => 'query Publication {
publication(host: "' . $this->host . '") {
posts(first: 10, filter: { tagSlugs: ["'.$tag.'"] }) {
edges {
node {
title
slug
brief
readTimeInMinutes
publishedAt
views
url
coverImage {
url
}
tags {
name
slug
}
author {
name
username
profilePicture
}
}
}
}
}
}'
]);
$publication = $response->json()['data']['publication'];
if ($publication === null) {
abort(400, 'Hashnode host not found');
}
return $publication['posts'];
}
Now create a tag.blade.php
file and enter:
@extends('layouts.app')
@section('title', "Post by Tag: $tag")
@section('content')
<p class="py-5 text-xl">Posts by tag: {{ $tag }}</p>
@include('blog.posts', ['posts' => $posts])
@endsection
This will load the posts up and display a message of "Posts by tag: X"
That's it, now you can filter posts by clicking on any tag that exists.
Implement pagination
The Hashnode API uses paging for posts, looking over our query to read posts we have posts(first: 10)
which loads up 10 posts.
This is great and very fast but how do we get the next page of posts?
What we need to do is update the existing query to return paging information in its response.
We need to add pageInfo
to the fields:
pageInfo {
endCursor,
hasNextPage
}
This goes alongside the edges like this:
posts(first: 10) {
edges {
node {
title
slug
...
}
}
pageInfo {
endCursor,
hasNextPage
}
}
This will now return a pageInfo block in the response. If there is a next page then hasNextPage will be true. The endCursor can be used to tell the API to start from this cursor.
"pageInfo" => array:2 [▼
"endCursor" => "NjQxODg4ZWY4Y2ExNWM0MzdiMzE2ZmYxXzIwMjMtMDMtMThUMTI6MjQ6MDAuMDAwWg=="
"hasNextPage" => true
]
Inside the posts() method of the GraphQL query we can pass after: and pass in the endCursor.
Updating our query to support this now looks like this:
if (request()->has('next')) {
$after = request()->input('next');
} else {
$after = '';
}
$response = Http::post($this->url, [
'query' => 'query Publication {
publication(host: "' . $this->host . '") {
posts(first: 10, after: "' . $after . '") {
edges {
node {
title
slug
brief
readTimeInMinutes
publishedAt
views
url
coverImage {
url
}
tags {
name
slug
}
author {
name
username
profilePicture
}
}
}
pageInfo {
endCursor,
hasNextPage
}
}
}
}'
]);
All that's left is to add a button on the listing page to show the next button.
We would update the BlogControllers array to include the pageInfo
:
return view('blog.index', [
'posts' => $hashnodeService->getPosts()['edges'],
'pageInfo' => $hashnodeService->getPosts()['pageInfo'],
]);
And in any view add a button as long as there is a next page. It makes sense to add this to the end of the posts.blade.php
file.
@if($pageInfo['hasNextPage'])
<div class="flex justify-center">
<a class="bg-indigo-600 text-white px-2 py-2.5 mb-10 rounded-md" href="{{ url('?next='.$pageInfo['endCursor']) }}">Next Page</a>
</div>
@endif
Update both methods of the blog controller to return the pageInfo
:
public function index(HashnodeService $hashnodeService)
{
return view('blog.index', [
'posts' => $hashnodeService->getPosts()['edges'],
'pageInfo' => $hashnodeService->getPosts()['pageInfo'],
]);
}
public function tag(HashnodeService $hashnodeService, $tag)
{
return view('blog.tag', [
'tag' => $tag,
'posts' => $hashnodeService->getPostsByTag($tag)['edges'],
'pageInfo' => $hashnodeService->getPosts()['pageInfo'],
]);
}
Viewing a post
Listing posts and filtering by tags is in place, let's tackle seeing a single post.
We want a post to load up when going to /{slug}
to accommodate this create a route, and ensure it's the last route as it's what's called a catch-all route.
A catch-up route is a route that will catch anything you pass and will then stop, so it needs to come after all other routes.
Route::get('{slug}', [BlogController::class, 'show']);
Next, create a method in the controller:
public function show(HashnodeService $hashnodeService, $slug)
{
return view('blog.show', [
'post' => $hashnodeService->getPost($slug),
]);
}
Now we need to add a getPost($slug)
method to the Hashnode service.
This query uses a post method which accepts a slug. In the fields instead of requesting brief
a field called content
is used. Content needs to be told which format to use, in this example we've asked for both html and markdown:
public function getPost(string $slug): array
{
$response = Http::post($this->url, [
'query' => 'query Publication {
publication(host: "' . $this->host . '") {
post(slug: "'.$slug.'") {
title
slug
content {
html,
markdown
}
readTimeInMinutes
publishedAt
url
coverImage {
url
}
tags {
name
slug
}
author {
name
username
profilePicture
}
}
}
}'
]);
$post = $response->json()['data']['publication']['post'];
if ($post === null) {
abort(404);
}
return $post;
}
If the post does not exist then we fire off a 404 event otherwise the post is returned.
Now we need to create a show.blade.php
file to show the post. This will be similar to the post file but instead of looping we can access $post
right away:
For syntax highlighting we're using highligh.js
to define the markup inside a @section('meta')
this allows this to be injected into the layout file so only this page will contain this code.
@extends('layouts.app')
@section('title', $post['title'])
@section('meta')
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/default.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/php.min.js"></script>
<script>hljs.highlightAll();</script>
@endsection
@section('content')
<div class="container max-w-screen-lg pb-16 mx-auto">
<div class="overflow-hidden mb-10 px-8 py-4 rounded-lg">
@if(!empty($post['coverImage']['url']))
<div class="flex-shrink-0">
<a href="{{ url($post['slug']) }}" class="block">
<img class="w-full object-cover rounded-t-lg" src="{{ $post['coverImage']['url'] }}" alt="">
</a>
</div>
@endif
<h1 class="text-white text-4xl text-center mt-10">{{ $post['title'] }}</h1>
<p class="text-sm leading-5 font-medium text-primary mb-0">
@foreach($post['tags'] as $tag)
<a href="{{ url('tag/'.strtolower($tag['slug'])) }}">
{{ $tag['name'] }}
@if (!$loop->last) | @endif
</a>
@endforeach
</p>
<div class="flex items-center mt-6 mb-6">
@if(!empty($post['author']['profilePicture']))
<div class="flex-shrink-0">
<a href='{{ 'https://hashnode.com/@'.$post['author']['username'] }}'>
<img class="w-10 h-10 pr-1 rounded-full" src="{{ $post['author']['profilePicture'] }}" alt="{{ $post['author']['name'] }}">
</a>
</div>
@endif
<div class="ml-3">
<p class="text-sm mb-0 font-medium text-primary">
<a href="{{ 'https://hashnode.com/@'.$post['author']['username'] }}" class="hover:underline">{{ $post['author']['name'] }}</a>
</p>
<div class="flex text-sm leading-5 text-gray-500 dark:text-gray-200">
<p>{{ $post['readTimeInMinutes']}} min read - {{ date('jS M, Y', strtotime($post['publishedAt'])) }}</p>
</div>
</div>
</div>
<article class="mx-auto">
<p class="text-sm leading-5 font-medium text-primary mb-0">
@foreach($post['tags'] as $tag)
<a href="{{ url('tag/'.strtolower($tag['name'])) }}">
{{ $tag['name'] }}
@if (!$loop->last) | @endif
</a>
@endforeach
</p>
<div class="dark:prose lg:prose-xl">
{!! $post['content']['html'] !!}
</div>
</article>
</div>
</div>
@endsection
If the page does not look correct, and you are running npm run dev
then stop it and run npm run build
sometimes running the build provides better results.
Viewing a post by the URL domain/{slug}
{slug}
= being the post slug field that a post contains ie:
domain/a-sample-post
Will now render a post:
Setting up a test environment using Pest
My testing framework of choice is Pest let's install Pest using Composer:
Find out more about Pest at https://pestphp.com
composer require pestphp/pest --dev --with-all-dependencies
Since we're using Laravel it makes sense to install the Laravel plugin
composer require pestphp/pest-plugin-laravel --dev
Secondly, you'll need to initialize Pest in your current PHP project. This step will create a configuration file named Pest.php
at the root level of your test suite, which will enable you to fine-tune your test suite later.
./vendor/bin/pest --init
To run a test, at any time using Artisan:
php artisan test
Or use pest directly:
./vendor/bin/pest
This will run the following tests:
Make our test file called BlogTest
inside tests/Feature
Create a new file called BlogTest.php
Add a method that tests whether the blog listing page can load, in this case, the home page.
<?php
use function Pest\Laravel\get;
test('can see blog page', function () {
get('/')
->assertOk()
->assertViewHas('posts', function ($posts) {
return count($posts) === 2;
});
});
This checks we get a 200 status code when going to the home page.
Also, it checks there is a view called 'posts` and that the view received an array with 2 posts.
This will fail if the blog you're using has more than 2 posts as the test runs the application code and pulls down the posts from Hashnode.
Instead, let's fake this so we can control what data we're testing for.
Remember we don't want to test the API but we do want to task our application that uses the responses from the API.
Since the HashnodeService uses the HTTP client we can fake it by calling Http::fake
We can fake when a call is made to https://gql.hashnode.com don't actually run it and instead return a fake response:
Http::fake(['https://gql.hashnode.com/' => Http::response([
'data' => [
...
]
])
]);
Our updated test now looks like this:
test('can see blog page', function () {
Http::fake(['https://gql.hashnode.com/' => Http::response([
'data' => [
'publication' => [
'posts' => [
'edges' => [
[
'node' => [
'title' => 'My first post',
'slug' => 'my-first-post',
'brief' => 'This is my first post',
'readTimeInMinutes' => 1,
'publishedAt' => '2021-01-01',
'views' => 1,
'url' => 'https://dcblog.dev/my-first-post',
'coverImage' => [
'url' => 'https://dcblog.dev/my-first-post.jpg'
],
'tags' => [
[
'name' => 'Laravel',
'slug' => 'laravel'
]
],
'author' => [
'name' => 'John Doe',
'username' => 'johndoe',
'profilePicture' => 'https://dcblog.dev/johndoe.jpg'
]
],
], [
'node' => [
'title' => 'My second post',
'slug' => 'my-second-post',
'brief' => 'This is my second post',
'readTimeInMinutes' => 1,
'publishedAt' => '2021-01-01',
'views' => 1,
'url' => 'https://dcblog.dev/my-second-post',
'coverImage' => [
'url' => 'https://dcblog.dev/my-second-post.jpg'
],
'tags' => [
[
'name' => 'Laravel',
'slug' => 'laravel'
]
],
'author' => [
'name' => 'John Doe',
'username' => 'johndoe',
'profilePicture' => 'https://dcblog.dev/johndoe.jpg'
]
]
]
],
'pageInfo' => [
'endCursor' => '123',
'hasNextPage' => false
]
]
]
]
])
]);
get('/')
->assertOk()
->assertViewHas('posts', function ($posts) {
return count($posts) === 2;
});
});
This test will now pass as we have two posts in our fake response.
We can write a similar test to confirm a single post is returned:
test('posts by tags', function () {
Http::fake(['https://gql.hashnode.com/' => Http::response([
'data' => [
'publication' => [
'posts' => [
'edges' => [
[
'node' => [
'title' => 'My first post',
'slug' => 'my-first-post',
'brief' => 'This is my first post',
'readTimeInMinutes' => 1,
'publishedAt' => '2021-01-01',
'views' => 1,
'url' => 'https://dcblog.dev/my-first-post',
'coverImage' => [
'url' => 'https://dcblog.dev/my-first-post.jpg'
],
'tags' => [
[
'name' => 'Laravel',
'slug' => 'laravel'
]
],
'author' => [
'name' => 'John Doe',
'username' => 'johndoe',
'profilePicture' => 'https://dcblog.dev/johndoe.jpg'
]
]
]
],
'pageInfo' => [
'endCursor' => '123',
'hasNextPage' => false
]
]
]
]
])
]);
get('tag/laravel')
->assertOk()
->assertViewHas('posts', function ($posts) {
return count($posts) === 1;
});
});
Conclusion
In this article, we walked through the process of integrating Hashnode's Public API with a Laravel blog.
We explored how to use GraphQL with Laravel, set up routes, design the blog with TailwindCSS, and implement features like tags and pagination.
We also looked at how to view individual posts and set up a test environment using Pest.
This guide demonstrates how to leverage Hashnode's Public API to seamlessly fetch and display blog posts on a Laravel platform, providing a customized blogging experience while utilizing Hashnode's powerful backend.
The source code is available on GitHub at https://github.com/dcblogdev/hashblog
For more details on using Hashnode's API please visit https://apidocs.hashnode.com
Comments (1)
Laravel + Hashnode, best of both worlds. Will be getting stuck into this one.