A RESTful API for habit tracking built with Laravel 11, Laravel Sanctum token authentication, and the Service Repository Pattern architecture.
This API allows users to create a list of habits, perform daily check-ins, calculate streaks, completion rates, and view aggregate statistics — all endpoints are protected by authentication except register and login.
Built with:
| Technology | Version |
|---|---|
| PHP | ^8.2 |
| Laravel Framework | ^11.31 |
| Laravel Sanctum | ^4.0 |
| MySQL | 8.x |
- Features
- Service Repository Pattern
- Folder Structure
- Installation
- API Response Format
- Response Codes
- API Endpoints
- Postman Collection
- Testing
- Token-based authentication via Sanctum (register, login, logout).
- Full habit CRUD with pagination.
- Flexible frequency: daily (
daily) or specific days (specific_days). - Check-in / un-check per date with duplicate check-in prevention.
- Habit statistics: current streak, longest streak, 30-day completion rate, total check-ins.
- Overview statistics for all active habits.
- Policy-based authorization: only the habit owner can access their habits.
- Rate limiting on login/register endpoints (6 requests/minute).
- Consistent JSON response format.
The Service Repository Pattern is an architectural pattern that separates an application into layers with different responsibilities:
┌─────────────────────────────────────────────────────────────────────┐
│ Request (HTTP) │
│ │ │
│ ▼ │
│ CONTROLLER → receives request, validates, formats response │
│ │ (no business logic / DB queries) │
│ ▼ │
│ FORM REQUEST → input validation │
│ POLICY → authorization (who can access) │
│ │ │
│ ▼ │
│ SERVICE → business logic (application rules) │
│ │ (does not know where data comes from) │
│ ▼ │
│ REPOSITORY → data access (DB queries via Eloquent) │
│ (interface) → method contract, no implementation │
│ │ │
│ ▼ │
│ ELOQUENT MODEL → database table representation │
└─────────────────────────────────────────────────────────────────────┘
- Request enters the Controller.
- FormRequest validates the input before it is passed on.
- Policy ensures the user only accesses their own data.
- The Controller calls the Service — this is where the business logic lives (e.g., duplicate check-in check, streak calculation).
- The Service calls the Repository Interface.
- RepositoryServiceProvider resolves the interface into an Eloquent implementation.
- The repository implementation runs the database query.
- The result is passed back up to the controller, wrapped by a Resource, and sent as JSON.
| Benefit | Explanation |
|---|---|
| Separation of concerns | Controller (HTTP), Service (business), Repository (data) — one responsibility per layer. |
| Testability | Services can be tested with mocked repositories without touching the database. |
| Swappable storage | Changing the storage only requires creating a new repository implementation and updating the binding. |
| Consistency | Business logic is not duplicated in multiple places. |
The repository interfaces are registered with the Laravel container in app/Providers/RepositoryServiceProvider.php:
public function register(): void
{
$this->app->bind(HabitRepositoryInterface::class, HabitRepository::class);
$this->app->bind(HabitLogRepositoryInterface::class, HabitLogRepository::class);
}When Laravel needs a HabitRepositoryInterface (e.g., inside HabitService), it automatically returns a HabitRepository. Without this binding the application would fail, because an interface cannot be instantiated directly.
app/
├── Enums/
│ └── FrequencyType.php # Frequency type enum (daily, specific_days)
├── Http/
│ ├── Controllers/Api/V1/ # Controllers for every endpoint
│ │ ├── AuthController.php
│ │ ├── HabitController.php
│ │ ├── HabitLogController.php
│ │ └── StatsController.php
│ ├── Requests/ # Form validation
│ │ ├── Auth/ # RegisterRequest, LoginRequest
│ │ └── Habit/ # StoreHabitRequest, UpdateHabitRequest, CheckInRequest
│ └── Resources/ # JSON formatters
│ ├── UserResource.php
│ ├── HabitResource.php
│ ├── HabitLogResource.php
│ └── HabitStatsResource.php
├── Models/ # Eloquent Models
│ ├── User.php
│ ├── Habit.php
│ └── HabitLog.php
├── Policies/
│ └── HabitPolicy.php # Habit ownership authorization
├── Providers/
│ └── RepositoryServiceProvider.php # Interface → implementation binding
├── Repositories/
│ ├── Contracts/ # Interfaces (contracts)
│ │ ├── HabitRepositoryInterface.php
│ │ └── HabitLogRepositoryInterface.php
│ └── Eloquent/ # Implementations
│ ├── HabitRepository.php
│ └── HabitLogRepository.php
└── Services/ # Business logic
├── HabitService.php
├── HabitLogService.php
└── StreakCalculatorService.php
- PHP ^8.2
- Composer
- MySQL 8.x
-
Clone the repository
git clone https://github.com/yogaarrd/Habit-Tracker-API-Laravel-Service-Repository-Pattern-API.git Laravel-API cd Laravel-API -
Install PHP dependencies
composer install
-
Create the environment file
cp .env.example .env
Then adjust the database configuration in
.env:DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=laravel_habit_tracker_api DB_USERNAME=root DB_PASSWORD=
-
Create the MySQL database
mysql -u root -p -e "CREATE DATABASE laravel_habit_tracker_api" -
Generate the application key
php artisan key:generate
-
Run migrations + seeder
php artisan migrate --seed
The seeder creates a demo account:
- Email:
demo@example.com - Password:
password
- Email:
-
Start the server
php artisan serve
The API is accessible at
http://127.0.0.1:8000.
All endpoints return JSON with a consistent envelope structure:
{
"success": true,
"data": { ... },
"error": null,
"meta": { ... }
}| Field | Type | Description |
|---|---|---|
success |
boolean | true if the request succeeded, false if it failed |
data |
mixed | Main payload (object, array, or null) |
error |
string/null | Error message (if any) |
meta |
object/null | Additional information such as pagination |
Example duplicate check-in error response (409):
{
"success": false,
"data": null,
"error": "Habit ini sudah di chek-in untuk tanggal tersebut",
"meta": null
}| Code | Meaning | Description |
|---|---|---|
200 |
OK | Request succeeded |
201 |
Created | Data was created (new habit, check-in) |
401 |
Unauthorized | Token is missing / invalid |
403 |
Forbidden | Not allowed (habit belongs to another user) |
404 |
Not Found | Habit not found |
409 |
Conflict | Duplicate check-in on the same date |
422 |
Unprocessable Entity | Validation failed |
429 |
Too Many Requests | Too many requests (login/register rate limit) |
500 |
Internal Server Error | Server error |
Base URL:
http://127.0.0.1:8000/api/v1Authentication: Send the token in the
Authorization: Bearer <token>header for all endpoints exceptregisterandlogin.
Creates a new account and returns an access token.
Request Body (JSON):
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | ✅ | User name |
email |
string | ✅ | Unique email |
password |
string | ✅ | At least 8 characters, must contain letters & numbers |
password_confirmation |
string | ✅ | Must match password |
Example:
curl -X POST http://127.0.0.1:8000/api/v1/register \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"name": "Budi",
"email": "budi@example.com",
"password": "rahasia123",
"password_confirmation": "rahasia123"
}'Response 200:
{
"success": true,
"data": {
"user": {
"id": 1,
"name": "Budi",
"email": "budi@example.com"
},
"token": "1|xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
},
"error": null,
"meta": null
}Authenticates a registered user.
Request Body (JSON):
| Field | Type | Required | Description |
|---|---|---|---|
email |
string | ✅ | Registered email |
password |
string | ✅ | Password |
Example:
curl -X POST http://127.0.0.1:8000/api/v1/login \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"email": "demo@example.com", "password": "password"}'Response 200:
{
"success": true,
"data": {
"user": {
"id": 1,
"name": "Demo User",
"email": "demo@example.com"
},
"token": "1|xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
},
"error": null,
"meta": null
}
⚠️ Login and register are limited to 6 requests per minute (throttle:6,1). Code429is returned when the limit is exceeded.
Revokes the active token. Once logged out, the token can no longer be used.
Example:
curl -X POST http://127.0.0.1:8000/api/v1/logout \
-H "Authorization: Bearer <token>" \
-H "Accept: application/json"Response 200:
{
"success": true,
"data": null,
"error": null,
"meta": null
}Returns the profile of the currently authenticated user.
Example:
curl http://127.0.0.1:8000/api/v1/user \
-H "Authorization: Bearer <token>" \
-H "Accept: application/json"Response 200:
{
"success": true,
"data": {
"id": 1,
"name": "Demo User",
"email": "demo@example.com"
},
"error": null,
"meta": null
}Returns all habits belonging to the authenticated user, ordered by newest, with pagination.
Query Parameters (optional):
| Parameter | Type | Description |
|---|---|---|
is_active |
boolean | Filter 1/true = active, 0/false = inactive |
page |
integer | Page number (default 1) |
Example:
curl "http://127.0.0.1:8000/api/v1/habits?is_active=1&page=1" \
-H "Authorization: Bearer <token>" \
-H "Accept: application/json"Response 200:
{
"success": true,
"data": [
{
"id": 1,
"name": "Minum air 2 liter",
"description": "Target hidrasi harian",
"frequency_type": "daily",
"frequency_days": null,
"is_active": true,
"created_at": "2026-08-12T06:42:00.000000Z",
"updated_at": "2026-08-12T06:42:00.000000Z"
}
],
"error": null,
"meta": {
"page": 1,
"per_page": 15,
"total": 1
}
}Creates a new habit. Returns code 201.
Request Body (JSON):
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | ✅ | Habit name |
description |
string | ❌ | Description (max 1000 characters) |
frequency_type |
string | ✅ | daily or specific_days |
frequency_days |
array | Required if frequency_type=specific_days. Array of day numbers: 0=Sunday, 1=Monday, ..., 6=Saturday |
* frequency_days is automatically null for the daily type.
Example — daily habit:
curl -X POST http://127.0.0.1:8000/api/v1/habits \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"name": "Baca buku 20 menit",
"description": "Membaca sebelum tidur",
"frequency_type": "daily"
}'Example — specific days habit (Monday & Thursday):
{
"name": "Berolahraga",
"frequency_type": "specific_days",
"frequency_days": [1, 4]
}Response 201:
{
"success": true,
"data": {
"id": 2,
"name": "Baca buku 20 menit",
"description": "Membaca sebelum tidur",
"frequency_type": "daily",
"frequency_days": null,
"is_active": true,
"created_at": "2026-08-12T07:00:00.000000Z",
"updated_at": "2026-08-12T07:00:00.000000Z"
},
"error": null,
"meta": null
}Returns the habit detail along with its statistics.
Example:
curl http://127.0.0.1:8000/api/v1/habits/2 \
-H "Authorization: Bearer <token>" \
-H "Accept: application/json"Response 200:
{
"success": true,
"data": {
"id": 2,
"name": "Baca buku 20 menit",
"description": "Membaca sebelum tidur",
"frequency_type": "daily",
"frequency_days": null,
"is_active": true,
"created_at": "2026-08-12T07:00:00.000000Z",
"updated_at": "2026-08-12T07:00:00.000000Z",
"stats": {
"current_streak": 3,
"longest_streak": 5,
"completion_rate_30d": 70,
"total_check_ins": 12,
"checked_in_today": true
}
},
"error": null,
"meta": null
}Updates habit data. All fields are optional (only the fields sent are updated).
Request Body (JSON):
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | ❌ | Habit name |
description |
string | ❌ | Description |
frequency_type |
string | ❌ | daily or specific_days |
frequency_days |
array | Required if frequency_type=specific_days |
|
is_active |
boolean | ❌ | true = active, false = inactive |
Example — deactivate a habit:
curl -X PATCH http://127.0.0.1:8000/api/v1/habits/2 \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"is_active": false}'Response 200: same as habit detail (without stats).
Deletes the habit along with all its check-in logs (cascade).
Example:
curl -X DELETE http://127.0.0.1:8000/api/v1/habits/2 \
-H "Authorization: Bearer <token>" \
-H "Accept: application/json"Response 200:
{
"success": true,
"data": null,
"error": null,
"meta": null
}Statistics for a single habit.
Example:
curl http://127.0.0.1:8000/api/v1/habits/2/stats \
-H "Authorization: Bearer <token>" \
-H "Accept: application/json"Response 200:
| Field | Description |
|---|---|
current_streak |
Number of consecutive fulfilled days up to today |
longest_streak |
Longest streak record of all time |
completion_rate_30d |
Completion percentage for the last 30 days |
total_check_ins |
Total number of check-ins |
checked_in_today |
Whether checked in today |
{
"success": true,
"data": {
"current_streak": 3,
"longest_streak": 5,
"completion_rate_30d": 70,
"total_check_ins": 12,
"checked_in_today": true
},
"error": null,
"meta": null
}Records a habit completion on a given date. Returns code 201.
Request Body (JSON):
| Field | Type | Required | Description |
|---|---|---|---|
date |
string | ❌ | Format YYYY-MM-DD. Default = today. Cannot be later than today |
note |
string | ❌ | Note (max 255 characters) |
Example:
curl -X POST http://127.0.0.1:8000/api/v1/habits/2/check-in \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"date": "2026-08-12", "note": "Berhasil hari ini"}'Response 201:
{
"success": true,
"data": {
"id": 5,
"logged_at": "2026-08-12",
"note": "Berhasil hari ini"
},
"error": null,
"meta": null
}Response 409 (already checked in on that date):
{
"success": false,
"data": null,
"error": "Habit ini sudah di chek-in untuk tanggal tersebut",
"meta": null
}Removes a check-in on a specific date.
Request Body (JSON):
| Field | Type | Required | Description |
|---|---|---|---|
date |
string | ❌ | Format YYYY-MM-DD. Default = today |
Example:
curl -X DELETE http://127.0.0.1:8000/api/v1/habits/2/check-in \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"date": "2026-08-12"}'Response 200:
{
"success": true,
"data": null,
"error": null,
"meta": null
}Returns the check-in history with pagination and date filtering.
Query Parameters (optional):
| Parameter | Type | Description |
|---|---|---|
from |
string | Start date filter (YYYY-MM-DD) |
to |
string | End date filter (YYYY-MM-DD) |
page |
integer | Page number (default 1) |
Example:
curl "http://127.0.0.1:8000/api/v1/habits/2/logs?from=2026-07-01&to=2026-08-12&page=1" \
-H "Authorization: Bearer <token>" \
-H "Accept: application/json"Response 200:
{
"success": true,
"data": [
{
"id": 5,
"logged_at": "2026-08-12",
"note": "Berhasil hari ini"
},
{
"id": 4,
"logged_at": "2026-08-11",
"note": null
}
],
"error": null,
"meta": {
"page": 1,
"per_page": 30,
"total": 2
}
}Summary statistics for all active habits belonging to the authenticated user.
Example:
curl http://127.0.0.1:8000/api/v1/stats/overview \
-H "Authorization: Bearer <token>" \
-H "Accept: application/json"Response 200:
{
"success": true,
"data": {
"total_active_habits": 2,
"completed_today": 2,
"best_current_streak": 7,
"habits": [
{
"habit_id": 1,
"name": "Minum air 2 liter",
"current_streak": 7,
"longest_streak": 7,
"completion_rate_30d": 100,
"total_check_ins": 12,
"checked_in_today": true
},
{
"habit_id": 3,
"name": "Olahraga pagi",
"current_streak": 0,
"longest_streak": 2,
"completion_rate_30d": 40,
"total_check_ins": 5,
"checked_in_today": false
}
]
},
"error": null,
"meta": null
}A ready-to-use Postman collection is available at:
postman/Habit Tracker API - Laravel Service Repository Pattern - Yogaardiana.postman_collection.json
How to use:
- Open Postman → Import → select the collection file.
- Create an Environment Variable named
tokento store the token from the login response. - Call Login first, then either configure a test script to store the token automatically, or fill it in manually on the Authorization tab.
Run the PHPUnit test suite:
php artisan test
⚠️ Tests currently use the MySQL database connection as configured in.env. Make sure the database is available before running the tests.
This project is open source and distributed under the MIT license.