In this tutorial we’ll create Laravel 10 CRUD API using Breeze API stack and then on frontend we’ll utilize Next.js based breeze-next starter kit.
Laravel 10.x requires a minimum PHP version of 8.1. This tutorial assumes that you are using Linux, macOS or WSL on Windows and Node.js, Composer, Git and PHP 8.1 along with required modules i.e., bcmatch, sqlite, mbstring, xml, zip, gd, mcrypt, curl are properly installed. Also make sure that SQLite is installed as we’ll be using SQLite to keep things simple and quick; you may use MySQL or PostgreSQL as you like, however make sure the corresponding PHP module is installed too.
Using composer, enter following command in terminal to install Laravel 10:
composer create-project laravel/laravel app 10.*
Then, go inside newly created app folder by executing:
cd app
Next, add Breeze package by executing following command in parent folder (app):
composer require laravel/breeze:* --dev
Then, install Breeze with API scaffolding by executing following command:
php artisan breeze:install api
Next, setup database and Laravel environment file. Here, I’m configuring SQLite database for simplicity purposes. Being inside parent folder (app), execute following command to create new SQLite database file;
touch database/database.sqlite
Then find .env config file in the parent folder (app), open it and change DB_CONNECTION line as follows:
DB_CONNECTION = sqlite
Remove DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD from .env as we are using SQLite database.
Since, initial setup of Laravel app is complete, we can move towards creating model, migration and controller. First enter the following command in parent folder to create Post model along with related migration and controller:
php artisan make:model -mrc Post
Open newly created migration file database/migrations/(date_stamp)_create_posts_table.php and then insert the table fields as follows:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('content');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('posts');
}
};
Enter following command in parent folder to migrate the above migration:
php artisan migrate
Now, open app/Models/Post.php model file and insert protected fillable array as follows:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
use HasFactory;
protected $fillable = [
'title', 'content'
];
}
Then, open app/Http/Controllers/PostController.php and amend it as follows in order to define basic CRUD (Create, Read, Update, Delete) functions:
<?php
namespace App\Http\Controllers;
use Illuminate\Routing\Controller as BaseController;
use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class PostController extends BaseController
{
public function index(): JsonResponse
{
$posts = Post::all()->toArray();
return response()->json($posts);
}
public function create()
{
}
public function store(Request $request) : JsonResponse
{
$validated = $request->validate([
'title' => 'required',
'content' => 'required',
]);
$post = Post::create($validated);
return response()->json(['status' => 'Post created.']);
}
public function show(Post $post)
{
}
public function edit(Post $post) : JsonResponse
{
$record = Post::find($post->id)->toArray();
return response()->json($record);
}
public function update(Request $request, Post $post) : JsonResponse
{
$validated = $request->validate([
'title' => 'required',
'content' => 'required',
]);
$post->update($validated);
return response()->json(['status' => 'Post updated.']);
}
public function destroy(Post $post) : JsonResponse
{
$post->delete();
return response()->json(['status' => 'Post deleted.']);
}
}
Next, open routes/api.php and put the following line at the upper side of it:
use App\Http\Controllers\PostController;
And then insert following resource route somewhere down inside routes/api.php:
Route::resource('posts', PostController::class);
Finally, run PHP dev server by executing:
php artisan serv
We’re done with backend of our Laravel 10 CRUD API using Breeze API stack and our backend server is running on port 8000 ready for accepting API calls.
Now we move onto frontend which will be based on Next.js – so, let’s open new Terminal window and then execute following command to clone Next.js based frontend starter kit:
git clone https://github.com/laravel/breeze-next.git
Then, head into breeze-next folder by executing:
cd breeze-next
Next, copy .env.example to .env.local by executing:
cp .env.example .env.local
Run following command to install npm modules:
npm install
Next, create a folder named posts inside src/pages by executing:
mkdir src/pages/posts
And then, create following three files inside src/pages/posts folder;
- index.js
- create.js
- edit.js
You may enter following command in order to create these files in one go:
touch src/pages/posts/{index,create,edit}.js
Then, go inside src/pages/posts folder and insert following code into src/pages/posts/index.js:
import AppLayout from '@/components/Layouts/AppLayout'
import Head from 'next/head'
import Link from 'next/link'
import Button from '@/components/Button'
import axios from '@/lib/axios'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import AuthSessionStatus from '@/components/AuthSessionStatus'
export async function getServerSideProps() {
const res = await fetch(`http://localhost:8000/api/posts`)
const data = await res.json()
return { props: { data } }
}
export default function Index({ data }) {
const [errors, setErrors] = useState([])
const [status, setStatus] = useState(null)
const router = useRouter()
useEffect(() => {
if (router.query.status?.length > 0 && errors.length === 0) {
setStatus(router.query.status)
} else {
setStatus(null)
}
})
const del = (id) => {axios.delete(`/api/posts/${id}`)
.then(res => {
router.push({
pathname:`/posts`,
query:{ status: res.data.status }
},'/posts')
})
.catch(error => {
if (error.response.status !== 422) throw error
setErrors(error.response.data.errors)
})
}
const edit = (id) => {axios.get(`/api/posts/${id}/edit`)
.then(res => {
router.push({
pathname:`/posts/edit`,
query:{ id: res.data.id, title: res.data.title, content:res.data.content }
},'/posts/edit')
})}
return (
<AppLayout
header={
<h2 className="font-semibold text-xl text-gray-800 leading-tight">
Posts
</h2>
}>
<Head>
<title>Posts</title>
</Head>
<AuthSessionStatus className="bg-gray-300 font-bold text-blue-900 p-1 m-2" status={status} />
<div className="m-6">
<Link href="/posts/create" className="py-2 px-4 m-4 bg-gray-50 text-gray-600 font-bold border border-gray-400 hover:bg-white hover:drop-shadow-md rounded-md">Create Post</Link>
</div>
<div className="bg-white rounded-md overflow-x-auto m-6 drop-shadow-lg">
<table className="w-full whitespace-nowrap">
<thead>
<tr className="bg-gray-500 text-white font-extrabold">
<th className="px-4 py-1 border">Title</th>
<th className="px-4 py-1 border">Content</th>
<th className="px-4 py-1 border">Actions</th>
</tr>
</thead>
<tbody>
{data.map(post => (
<tr key="post.id">
<td className="px-4 py-1 border">{post.title}</td>
<td className="px-4 py-1 border">{post.content}</td>
<td className="px-4 py-1 border">
<Button className="ml-4" onClick={()=>edit(post.id)}>Edit</Button>
<Button className="ml-4 bg-red-600 hover:bg-red-500" onClick={()=>del(post.id)}>Delete</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</AppLayout>
);
}
Then, insert following code inside src/pages/posts/create.js:
import AppLayout from '@/components/Layouts/AppLayout'
import Head from 'next/head'
import Button from '@/components/Button'
import Input from '@/components/Input'
import InputError from '@/components/InputError'
import Label from '@/components/Label'
import { useState } from 'react'
import axios from '@/lib/axios'
import { useRouter } from 'next/router'
export default function Create() {
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [errors, setErrors] = useState([])
const router = useRouter()
const submitForm = event => {
event.preventDefault()
axios.post('/api/posts', {'title':title, 'content':content})
.then(res => {router.push({
pathname:`/posts`,
query:{ status: res.data.status }
},'/posts')})
.catch(error => {
if (error.response.status !== 422) throw error
setErrors(error.response.data.errors)
})
}
return (
<AppLayout
header={
<h2 className="font-semibold text-xl text-gray-800 leading-tight">
Posts
</h2>
}>
<Head>
<title>Posts</title>
</Head>
<form onSubmit={submitForm}>
<div className="flex flex-col justify-start items-center mt-6">
<div>
<Label htmlFor="title">Title</Label>
<Input
id="title"
type="text"
value={title}
className="block mt-1 w-full"
onChange={event => setTitle(event.target.value)}
autoFocus
/>
<InputError messages={errors.title} className="mt-2" />
</div>
<div className="mt-4">
<Label htmlFor="content">Content</Label>
<Input
id="content"
type="text"
value={content}
className="block mt-1 w-full"
onChange={event => setContent(event.target.value)}
/>
<InputError messages={errors.content} className="mt-2" />
</div>
<div className="flex items-center justify-end mt-4">
<Button className="ml-4">Create</Button>
</div>
</div>
</form>
</AppLayout>
);
}
And then, insert following code inside src/pages/posts/edit.js:
import AppLayout from '@/components/Layouts/AppLayout'
import Head from 'next/head'
import Button from '@/components/Button'
import Input from '@/components/Input'
import InputError from '@/components/InputError'
import Label from '@/components/Label'
import { useState } from 'react'
import axios from '@/lib/axios'
import { useRouter } from 'next/router'
export default function Edit() {
const router = useRouter()
const [title, setTitle] = useState(router.query['title'])
const [content, setContent] = useState(router.query['content'])
const [errors, setErrors] = useState([])
const submitForm = event => {
event.preventDefault()
axios.put(`/api/posts/${router.query['id']}`, {'title':title, 'content':content})
.then(res => {router.push({
pathname:`/posts`,
query:{ status: res.data.status }
},'/posts')})
.catch(error => {
if (error.response.status !== 422) throw error
setErrors(error.response.data.errors)
})
}
return (
<AppLayout
header={
<h2 className="font-semibold text-xl text-gray-800 leading-tight">
Posts
</h2>
}>
<Head>
<title>Posts</title>
</Head>
<form onSubmit={submitForm}>
<div className="flex flex-col justify-start items-center mt-6">
<div>
<Label htmlFor="title">Title</Label>
<Input
id="title"
type="text"
value={title}
className="block mt-1 w-full"
onChange={event => setTitle(event.target.value)}
autoFocus
/>
<InputError messages={errors.title} className="mt-2" />
</div>
<div className="mt-4">
<Label htmlFor="content">Content</Label>
<Input
id="content"
type="text"
value={content}
className="block mt-1 w-full"
onChange={event => setContent(event.target.value)}
/>
<InputError messages={errors.content} className="mt-2" />
</div>
<div className="flex items-center justify-end mt-4">
<Button className="ml-4">Update</Button>
</div>
</div>
</form>
</AppLayout>
);
}
Run Vite dev server by executing following command:
npm run dev
Now, we have client-side running at port 3000 in addition to port 8000 where PHP dev server is already running. So, open browser and enter following URL (don’t forget to create a login user first):
localhost:3000/posts