Build a To-Do REST API with BowPHP: CRUD and JWT Authentication
We already covered the server-rendered To-Do list β HTML forms, sessions, the works. This time we'll build its API counterpart: a stateless JSON REST API where clients authenticate with a JWT and every task belongs to the user who created it.
By the end you'll have register/login endpoints that hand out a token, and a full set of CRUD routes that only ever touch the current user's tasks.
The planβ
- Store tasks with a
user_idso each one belongs to someone. - Hand out a JWT on register/login.
- Protect the task routes with the
apiguard and scope every query to the authenticated user.
Step 1 β The migrationsβ
We need a users table (a fresh app already ships one) and a tasks table that points back to it:
php bow add:migration create-tasks-table
use Bow\Database\Migration\Migration;
use Bow\Database\Migration\SQLGenerator as Table;
class Version1000000000CreateTasksTable extends Migration
{
public function up()
{
$this->create('tasks', function (Table $table) {
$table->addIncrement('id');
$table->addInteger('user_id', ['unsigned' => true]);
$table->addString('title');
$table->addBoolean('completed', ['default' => false]);
$table->addTimestamps();
$table->addForeign('user_id', [
'references' => 'id',
'on' => 'users',
'onDelete' => 'CASCADE',
]);
});
}
public function rollback()
{
$this->dropIfExists('tasks');
}
}
php bow migrate
Step 2 β The modelsβ
For JWT to work, the User model must extend Bow\Auth\Authentication rather than the plain Model:
namespace App\Models;
use Bow\Auth\Authentication;
class User extends Authentication
{
protected ?string $table = 'users';
}
The Task model is a regular Barry model:
namespace App\Models;
use Bow\Database\Barry\Model;
class Task extends Model
{
protected ?string $table = 'tasks';
}
Step 3 β Enable the JWT guardβ
JWT support comes from the official bowphp/policier package:
composer require bowphp/policier
Give it a signing key in .env.jsonβ¦
{
"APP_JWT_SECRET": "a-long-random-secret"
}
β¦and make sure config/auth.php declares an api guard backed by JWT:
'api' => [
'type' => 'jwt',
'model' => App\Models\User::class,
'credentials' => [
'username' => 'email',
'password' => 'password',
],
],
Finally, wire Policier into the kernel so the package boots with the app and the api middleware can validate incoming tokens:
public function configurations(): array
{
return [
\Policier\Bow\PolicierConfiguration::class,
// ...other configurations
];
}
The default web guard still works for session-based pages. The api guard is purely stateless β it reads the Authorization: Bearer ... header on each request and never touches the session.
Step 4 β Registration and loginβ
The auth controller does three things: create an account, exchange credentials for a token, and report who's logged in.
namespace App\Controllers\Api;
use App\Models\User;
use Bow\Auth\Auth;
use Bow\Http\Request;
use Bow\Security\Hash;
class AuthController
{
public function register(Request $request)
{
$validation = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users',
'password' => 'required|min:8',
]);
if ($validation->fails()) {
return response()->json(['errors' => $validation->getMessages()], 422);
}
$user = User::create([
'name' => $request->get('name'),
'email' => $request->get('email'),
'password' => Hash::make($request->get('password')),
]);
$user->persist();
return response()->json($user, 201);
}
public function login(Request $request)
{
$credentials = $request->only(['email', 'password']);
if (!Auth::guard('api')->attempts($credentials)) {
return response()->json(['error' => 'Invalid credentials'], 401);
}
$token = Auth::guard('api')->getToken();
return response()->json([
'token' => $token->getValue(),
'expires_in' => $token->get('exp'),
]);
}
public function me()
{
return response()->json(Auth::guard('api')->user());
}
}
Note we hash the password with Hash::make() on the way in; attempts() checks it for us on the way back.
Step 5 β The task controllerβ
Here's the heart of it. Every method resolves the current user first, then works only within that user's tasks β there's no way to read or touch someone else's data, even by guessing an id.
namespace App\Controllers\Api;
use App\Models\Task;
use Bow\Auth\Auth;
use Bow\Http\Request;
class TaskController
{
public function index()
{
$tasks = Task::where('user_id', Auth::guard('api')->id())->get();
return response()->json($tasks);
}
public function store(Request $request)
{
$validation = $request->validate([
'title' => 'required|min:3|max:255',
]);
if ($validation->fails()) {
return response()->json(['errors' => $validation->getMessages()], 422);
}
$task = Task::create([
'user_id' => Auth::guard('api')->id(),
'title' => $request->get('title'),
'completed' => false,
]);
$task->persist();
return response()->json($task, 201);
}
public function show(int $id)
{
$task = $this->findForUser($id);
if (!$task) {
return response()->json(['error' => 'Task not found'], 404);
}
return response()->json($task);
}
public function update(Request $request, int $id)
{
$task = $this->findForUser($id);
if (!$task) {
return response()->json(['error' => 'Task not found'], 404);
}
$task->update([
'title' => $request->get('title', $task->title),
'completed' => $request->get('completed', $task->completed),
]);
return response()->json($task);
}
public function destroy(int $id)
{
$task = $this->findForUser($id);
if (!$task) {
return response()->json(['error' => 'Task not found'], 404);
}
$task->delete();
return response()->json(['message' => 'Task deleted']);
}
/**
* Find a task that belongs to the authenticated user.
*/
private function findForUser(int $id): ?Task
{
return Task::where('id', $id)
->where('user_id', Auth::guard('api')->id())
->first();
}
}
Filtering on user_id in findForUser() is what makes the API safe. A user asking for /api/tasks/42 that belongs to someone else simply gets a 404 β the row never matches their user_id.
Step 6 β The routesβ
Two public endpoints get you a token; everything else lives behind the api middleware, which rejects any request without a valid JWT:
use App\Controllers\Api\AuthController;
use App\Controllers\Api\TaskController;
// Public β no token required
$app->post('/api/register', [AuthController::class, 'register']);
$app->post('/api/login', [AuthController::class, 'login']);
// Protected β valid JWT required
$app->prefix('/api')->middleware('api')->group(function () use ($app) {
$app->get('/me', [AuthController::class, 'me']);
$app->get('/tasks', [TaskController::class, 'index']);
$app->post('/tasks', [TaskController::class, 'store']);
$app->get('/tasks/:id', [TaskController::class, 'show']);
$app->put('/tasks/:id', [TaskController::class, 'update']);
$app->delete('/tasks/:id', [TaskController::class, 'destroy']);
});
Step 7 β Take it for a spinβ
Boot the server:
php bow run:server --port=8000
Register, then log in to grab a token:
# 1. Create an account
curl -X POST http://localhost:8000/api/register \
-H "Content-Type: application/json" \
-d '{"name":"Franck","email":"franck@example.com","password":"secret123"}'
# 2. Log in β copy the "token" from the response
curl -X POST http://localhost:8000/api/login \
-H "Content-Type: application/json" \
-d '{"email":"franck@example.com","password":"secret123"}'
Now use the token on the protected routes:
# Create a task
curl -X POST http://localhost:8000/api/tasks \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"Write the API blog post"}'
# List your tasks
curl http://localhost:8000/api/tasks \
-H "Authorization: Bearer YOUR_TOKEN"
# Mark one complete
curl -X PUT http://localhost:8000/api/tasks/1 \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"completed":true}'
Drop the Authorization header and the same calls come back 401 β exactly what you want from a protected API.
Going furtherβ
- Pagination β swap
->get()for->paginate(15)inindex()once a user has a lot of tasks. See the pagination docs. - Refresh tokens β issue a longer-lived token to mint fresh access tokens without forcing a re-login.
- Filtering β accept
?completed=trueand chain anotherwhere()onto the scoped query. - Rate limiting β add a middleware in front of
/api/loginto slow down brute-force attempts.
That's a complete, auth-protected REST API: a JWT guard for identity, per-user scoping for safety, and clean JSON in and out. See the Authentication and Policier docs for the full reference.
Is something missing?
If you run into problems with the documentation or have suggestions to improve the documentation or the project in general, please open an issue for us, or send a tweet mentioning the Twitter account @bowframework or directly on github.