Building a Laravel 12 API Using a HasQueryBuilder Trait
This tutorial walks you through:
- Creating a fresh Laravel 12 project
- Implementing a reusable
HasQueryBuildertrait for dynamic filtering, sorting, and pagination - Structuring your API with Route → Controller → Service → Model layers
- Building a CRUD API for the User model
Step 1: Create a New Laravel 12 Project
composer create-project laravel/laravel:^12.0 laravel-hqb-api
cd laravel-hqb-api
Configure your .env file for the database:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel_hqb_api
DB_USERNAME=root
DB_PASSWORD=
php artisan migrate
Step 2: Create the HasQueryBuilder Trait
Create file app/Traits/HasQueryBuilder.php with this content:
<?php
namespace App\Traits;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
trait HasQueryBuilder
{
/**
* --- QUERY BUILDER CONDITIONS STRUCTURE / クエリビルダー条件構造 ---
* * The $conditions array supports standard WHERE clauses and special control keys.
* $conditions配列は、標準のWHERE句と特別な制御キーをサポートします。
*
* 1. STANDARD FIELD CONDITIONS (標準フィールド条件):
* - Simple equality: ['name' => 'John Doe'] // WHERE name = 'John Doe'
* - Array as WHERE IN: ['status' => [1, 2, 3]] // WHERE status IN (1, 2, 3)
*
* 2. ADVANCED FIELD CONDITIONS (高度なフィールド条件):
* - Custom operator: ['age' => ['op' => '>', 'value' => 18]] // WHERE age > 18
* - LIKE condition: ['title' => ['like' => '%test%']] // WHERE title LIKE '%test%'
* - IS NULL/NOT NULL: ['deleted_at' => ['null' => true]] // WHERE deleted_at IS NULL
* - WHERE IN (explicit): ['user_id' => ['in' => [1, 5, 10]]]
* - WHERE BETWEEN: ['price' => ['between' => [100, 200]]]
* - RAW Field Condition: ['date' => ['raw' => 'DATE(created_at) > DATE(updated_at)']] // NOTE: Handled by __whereRaw or explicit WHERE methods
*
* 3. LOGIC GROUPING (ロジックグループ化):
* - AND Group: ['__and' => [['field1' => 'value'], ['field2' => ['op' => '!=', 'value' => 5]]]]
* - OR Group: ['__or' => [['field1' => 'value'], ['field2' => 'value']]] // OR (field1 = 'value' OR field2 = 'value')
*
* 4. SPECIAL CONTROL KEYS (特別な制御キー):
* - __select: (array) Custom selection of columns, supports DB::raw expressions (e.g., 'COUNT(id) as total_posts'). / カスタム選択カラム(DB::raw式をサポート)。
* - __whereRaw: (string|array) Raw WHERE clause(s) using whereRaw(). e.g., 'column1 = column2' or ['column1 = column2', 'column3 IS NOT NULL']. / whereRaw()を使用する生のWHERE句。
* - __joins: (array) Defines joins. See applyJoin for structure. / 結合を定義します。
* - __relations: (array) Filters by relationships (whereHas). e.g., ['posts' => ['status' => 1]]
* - __groupBy: (array|string) Columns to group by. e.g., ['category_id', 'status'] / GROUP BYするカラム。
* - __having: (array) Conditions applied after grouping. See applyCondition for structure. / グループ化後に適用される条件(HAVING句)。
* - __orderBy: (array) Sorting criteria. e.g., ['created_at' => 'desc', 'name' => 'asc']
* - __limit: (int) Max number of records. / レコードの最大数。
* - __offset: (int) Starting point offset. / オフセット。
* - __with: (array|string) Eager load relationships. e.g., ['profile', 'comments']
* - __paginate: (int) Records per page (Standard Pagination). / ページあたりのレコード数(標準ページネーション)。
* - __cursorPaginate: (int) Records per page (Cursor Pagination). / ページあたりのレコード数(カーソルページネーション)。
*/
/**
* Query Builder (クエリ構築 / Query Builder)
* The main method to build an Eloquent query based on conditions (filters, joins, order, limit, etc.).
* 条件(フィルタ、結合、順序、制限など)に基づいてEloquentクエリを構築する主要なメソッド。
*
* @param array|object $conditions
* @return Builder|\Illuminate\Contracts\Pagination\LengthAwarePaginator|\Illuminate\Contracts\Pagination\CursorPaginator|Collection
*/
public static function queryByConditions(array|object $conditions): Builder|\Illuminate\Contracts\Pagination\LengthAwarePaginator|\Illuminate\Contracts\Pagination\CursorPaginator|Collection
{
$conditions = is_object($conditions) && method_exists($conditions, 'all')
? $conditions->all()
: (array) $conditions;
/** @var \Illuminate\Database\Eloquent\Model $model */
$model = new static;
$query = $model->newQuery();
// 1. SELECT (カスタム選択)
// Apply custom select clauses specified in conditions.
if ($select = Arr::get($conditions, '__select')) {
self::applySelect($query, $select);
}
// 2. JOINS (結合)
// Apply joins specified in conditions.
if ($joins = Arr::get($conditions, '__joins', [])) {
foreach ($joins as $join) {
$query = self::applyJoin($query, $join);
}
}
// 3. FILTERS (フィルタ / WHERE条件)
self::applyFilters($query, $conditions, 'and');
// 3.1. RAW WHERE (生WHERE句)
// Apply raw WHERE clauses using whereRaw()
if ($whereRaw = Arr::get($conditions, '__whereRaw')) {
$whereRaw = is_array($whereRaw) ? $whereRaw : [$whereRaw];
foreach ($whereRaw as $rawClause) {
$query->whereRaw($rawClause);
}
}
// 4. GROUP BY & HAVING (グループ化とHAVING句)
self::applyGroupByAndHaving($query, $conditions);
// 5. ORDER BY (順序)
if ($orderBy = Arr::get($conditions, '__orderBy')) {
foreach ($orderBy as $field => $dir) {
$query->orderBy($field, $dir);
}
}
// 6. LIMIT & OFFSET (制限とオフセット)
if ($limit = Arr::get($conditions, '__limit')) {
$query->limit($limit);
}
if ($offset = Arr::get($conditions, '__offset')) {
$query->offset($offset);
}
// 7. WITH (リレーションのEagerロード)
if ($with = Arr::get($conditions, '__with')) {
$query->with(is_array($with) ? $with : [$with]);
}
// 8. PAGINATION (ページネーション)
// Cursor-based pagination takes priority if present.
if ($cursorPaginate = Arr::get($conditions, '__cursorPaginate')) {
$perPage = (int) $cursorPaginate;
// Returns CursorPaginator.
return $query->cursorPaginate($perPage, ['*'], 'cursor');
}
if ($paginate = Arr::get($conditions, '__paginate')) {
$perPage = (int) $paginate;
$currentPage = request()->query('page', 1);
// Returns LengthAwarePaginator if paginate is set.
return $query->paginate($perPage, ['*'], 'page', $currentPage);
}
return $query;
}
/**
* カスタムSELECT句を適用する (Apply Custom SELECT Clause)
* Supports both flat arrays and associative arrays for aliases/raw expressions.
* フラット配列と連想配列の両方をサポートし、エイリアス/生SQL式を適用します。
*
* @param Builder $query
* @param array $selectConditions
* @return void
*/
protected static function applySelect(Builder $query, array $selectConditions): void
{
$selects = [];
// Determine if array is associative (key is string = alias)
$isAssociative = count(array_filter(array_keys($selectConditions), 'is_string')) > 0;
if ($isAssociative) {
// Associative array: ['alias' => 'column/raw_expression']
foreach ($selectConditions as $alias => $expression) {
// If it looks like a function (e.g., COUNT(id)) or complex expression, use DB::raw
if (is_string($expression) && preg_match('/\((.*?)\)/', $expression)) {
// Raw expression with alias: COUNT(id) AS total_count
$selects[] = \Illuminate\Support\Facades\DB::raw("{$expression} AS {$alias}");
} else {
// Simple column alias: email AS user_email
$selects[] = "{$expression} as {$alias}";
}
}
} else {
// Flat array: ['column1', 'column2 as alias', 'COUNT(id) as total_count']
foreach ($selectConditions as $column) {
// If the item contains a function (e.g., COUNT(id) or SUM(price)), wrap it in DB::raw for safety.
if (is_string($column) && preg_match('/\(.*\)\s*(?:as\s+.*)?$/i', $column)) {
$selects[] = \Illuminate\Support\Facades\DB::raw($column);
} else {
$selects[] = $column;
}
}
}
if (!empty($selects)) {
$query->select($selects);
}
}
/**
* フィルターを適用する (WHERE条件を適用 / Apply Filters)
* Apply WHERE conditions based on the array of conditions.
* 条件配列に基づいてWHERE条件を適用します。
*
* @param Builder $query
* @param array $conditions
* @param string $boolean 'and' or 'or'
* @return void
*/
protected static function applyFilters(Builder $query, array $conditions, string $boolean = 'and'): void
{
$booleanMethod = $boolean === 'or' ? 'orWhere' : 'where';
// Define all special control keys to skip during field iteration.
$specialKeys = ['__select', '__joins', '__orderBy', '__limit', '__offset', '__with', '__relations', '__paginate', '__cursorPaginate', '__groupBy', '__having', '__whereRaw'];
// Filter by relationship (WHERE HAS).
if (!empty($conditions['__relations']) && is_array($conditions['__relations'])) {
foreach ($conditions['__relations'] as $relation => $relConds) {
$query->{$booleanMethod . 'Has'}($relation, function (Builder $q) use ($relConds) {
self::applyFilters($q, $relConds, 'and');
});
}
}
foreach ($conditions as $field => $value) {
if (in_array($field, $specialKeys, true)) {
continue;
}
// Handle nested AND condition groups.
if ($field === '__and' and is_array($value)) {
$query->{$booleanMethod}(function (Builder $q) use ($value) {
foreach ($value as $cond) {
if (is_array($cond)) {
self::applyFilters($q, $cond, 'and');
}
}
});
continue;
}
// Handle nested OR condition groups.
if ($field === '__or' and is_array($value)) {
$query->{$booleanMethod}(function (Builder $q) use ($value) {
foreach ($value as $cond) {
if (is_array($cond)) {
self::applyFilters($q, $cond, 'or');
}
}
});
continue;
}
// Apply single condition.
self::applyCondition($query, $field, $value, $booleanMethod);
}
}
/**
* グループ化とHAVING句を適用する (Apply Group By and Having)
* Applies GROUP BY and HAVING clauses if present in the conditions.
* 条件にGROUP BYおよびHAVING句がある場合に適用します。
*
* @param Builder $query
* @param array $conditions
* @return void
*/
protected static function applyGroupByAndHaving(Builder $query, array $conditions): void
{
// Apply GROUP BY
if ($groupBy = Arr::get($conditions, '__groupBy')) {
$groupBy = is_array($groupBy) ? $groupBy : [$groupBy];
$query->groupBy($groupBy);
}
// Apply HAVING conditions
if ($having = Arr::get($conditions, '__having')) {
// Check for explicit OR grouping within HAVING
if (isset($having['__or']) && is_array($having['__or'])) {
$query->orHaving(function (Builder $q) use ($having) {
foreach ($having['__or'] as $cond) {
if (is_array($cond)) {
foreach ($cond as $field => $value) {
self::applyCondition($q, $field, $value, 'orHaving');
}
}
}
});
// Remove __or to prevent double processing
unset($having['__or']);
}
// Apply all other HAVING conditions as AND
foreach ($having as $field => $value) {
// Skip special keys like __or that were already processed
if ($field === '__or') {
continue;
}
// If it is a group of AND conditions (which is not needed in HAVING), unwrap it
if ($field === '__and' && is_array($value)) {
foreach ($value as $subCond) {
foreach ($subCond as $subField => $subValue) {
self::applyCondition($query, $subField, $subValue, 'having');
}
}
continue;
}
self::applyCondition($query, $field, $value, 'having');
}
}
}
/**
* ジョイントを適用する (結合を適用 / Apply Joins)
* Applies a single join definition to the query builder.
* 単一の結合定義をクエリビルダーに適用します。
*
* @param Builder $query
* @param array $join
* @return Builder
*/
protected static function applyJoin(Builder $query, array $join): Builder
{
$type = strtolower($join['type'] ?? 'inner');
$table = $join['table'];
$localKey = $join['localKey'];
$operator = $join['operator'] ?? '=';
$foreignKey = $join['foreignKey'];
$query->join($table, $localKey, $operator, $foreignKey, $type);
// NOTE: We don't add select columns from join here anymore, as it interferes with custom __select.
if (!empty($join['conditions']) and is_array($join['conditions'])) {
foreach ($join['conditions'] as $field => $value) {
self::applyCondition($query, $field, $value);
}
}
return $query;
}
/**
* 条件を適用する (特定のWHERE条件を適用 / Apply a specific WHERE condition)
* Supports various syntaxes: [field => value], [field => ['in' => [a, b]]], [field => ['op' => '>', 'value' => 10]], etc.
* サポートされる構文: [field => value]、[field => ['in' => [a, b]]]、[field => ['op' => '>', 'value' => 10]]など。
*
* @param Builder $query
* @param string $field
* @param mixed $value
* @param string $method 'where', 'orWhere', 'having', or 'orHaving'
* @return void
*/
protected static function applyCondition(Builder $query, string $field, mixed $value, string $method = 'where'): void
{
$methodIn = $method . 'In';
$methodBetween = $method . 'Between';
$methodNull = $method . 'Null';
$methodNotNull = $method . 'NotNull';
// Adjust the method for simple operators (e.g., 'having' or 'where')
$simpleMethod = match(true) {
str_contains(strtolower($method), 'having') => 'having',
default => 'where',
};
match (true) {
// Handle 'whereIn' or 'havingIn'
is_array($value) and isset($value['in']) => $query->{$methodIn}($field, $value['in']),
// Handle 'whereBetween' or 'havingBetween'
is_array($value) and isset($value['between']) => $query->{$methodBetween}($field, $value['between']),
// Handle LIKE (Note: LIKE inside HAVING is generally database-dependent and uncommon, but supported here)
is_array($value) and isset($value['like']) => $query->{$simpleMethod}($field, 'LIKE', $value['like']), // Use simple 'where'/'having'
// Handle IS NULL / IS NOT NULL
is_array($value) and isset($value['null']) => $value['null']
? $query->{$methodNull}($field)
: $query->{$methodNotNull}($field),
// Handle custom operator (>, <, !=)
is_array($value) and isset($value['op'], $value['value']) => $query->{$method}($field, $value['op'], $value['value']),
// Default array is treated as 'whereIn' or 'havingIn'
is_array($value) => $query->{$methodIn}($field, $value),
// Single value
default => $query->{$method}($field, $value),
};
}
// --- CRUD/REPOSITORY METHODS (CRUD/リポジトリメソッド) ---
/**
* IDでデータを取得する (Retrieve data by ID)
* IDに基づいてデータを取得します。
*
* @param int|string $id
* @return static|null
*/
public static function getById(int|string $id): ?static
{
return self::find($id);
}
/**
* 複数のIDからデータを取得する (Retrieve data by multiple IDs)
* 複数のIDからデータを取得します。
*
* @param array $ids
* @return Collection
*/
public static function getByIds(array $ids): Collection
{
return self::whereIn((new self)->getKeyName(), $ids)->get();
}
/**
* データを作成する (Create a record)
* This method is renamed to 'createRecord' to avoid recursive conflict with the static create() method of Eloquent Model::create().
* このメソッドは、Eloquent Model::create()の静的create()メソッドとの再帰的な競合を避けるために'createRecord'に名前が変更されました。
*
* @param array $data
* @return static
*/
public static function createRecord(array $data): static
{
// Call the create() method of the main Model class.
return parent::create($data);
}
/**
* 複数のデータを作成する (Create multiple records)
* Creates multiple new records from an array of data arrays and returns a collection of the created models.
* データ配列の配列から複数の新規レコードを作成し、作成されたモデルのコレクションを返します。
*
* @param array $dataSets Array of data arrays.
* @return Collection<\static>
*/
public static function createMultiple(array $dataSets): Collection
{
$models = new Collection();
foreach ($dataSets as $data) {
$models->push(self::createRecord($data));
}
return $models;
}
/**
* データを更新する (Update a record by ID)
* IDに基づいてレコードを更新します。
*
* @param int|string $id
* @param array $data
* @return static|null
*/
public static function updateById(int|string $id, array $data): ?static
{
$model = self::find($id);
if ($model) {
$model->update($data);
return $model;
}
return null;
}
/**
* 複数のデータを条件付きで更新する (Update multiple records by conditions)
* Updates multiple records matching the given conditions and returns the number of updated rows.
* 指定された条件に一致する複数のレコードを更新し、更新された行の数を返します。
*
* @param array $conditions Conditions to filter the records.
* @param array $data The data to update.
* @return int Number of affected rows.
*/
public static function updateMultiple(array $conditions, array $data): int
{
// Note: queryByConditions must return a Builder instance for mass update to work.
$query = self::queryByConditions($conditions);
// Mass update can only be performed on the Builder instance.
if ($query instanceof Builder) {
return $query->update($data);
}
return 0;
}
/**
* IDでデータを削除する (Delete a record by ID)
* IDに基づいてレコードを削除します。
*
* @param int|string $id
* @return int (number of deleted records: 1 or 0)
*/
public static function deleteById(int|string $id): int
{
return self::destroy($id);
}
/**
* 複数のIDでデータを削除する (Delete records by multiple IDs)
* 複数のIDに基づいてレコードを削除します。
*
* @param array $ids
* @return int (number of deleted records)
*/
public static function deleteByIds(array $ids): int
{
return self::whereIn((new self)->getKeyName(), $ids)->delete();
}
/**
* --- TRAIT USAGE EXAMPLE (CRUD/FILTERING) / トレイト使用例 (CRUD/フィルタリング) ---
* This method demonstrates comprehensive usage of all CRUD and advanced filtering methods in this trait.
* このメソッドは、このトレイト内のすべてのCRUDおよび高度なフィルタリングメソッドの包括的な使用法を示しています。
*
* @return void
*/
public static function exampleUsage(): void
{
// =========================================================================
// 1. SELECT USAGE EXAMPLES / 1. SELECT句の使用例
// =========================================================================
// 1.1. Simple column selection (Flat Array)
// シンプルなカラム選択 (フラット配列): SELECT name, email AS user_email, status FROM ...
$simpleSelectConditions = [
'__select' => [
'name',
'email as user_email',
'status'
],
'status' => 1
];
// $simpleResults = self::queryByConditions($simpleSelectConditions)->get();
// 1.2. Custom Aggregation with Alias (Associative Array for Raw Expressions / DB::raw)
// エイリアス付きカスタム集計 (生SQL式のための連想配列 / DB::raw): SELECT category_id, COUNT(id) AS total_products FROM ...
$rawSelectConditions = [
'__select' => [
'category_id' => 'category_id', // Select column 'category_id'
'total_products' => 'COUNT(id)', // Raw function with alias
'max_price' => 'MAX(price)' // Raw function with alias
],
'__groupBy' => 'category_id',
'__orderBy' => [
'total_products' => 'desc'
]
];
// $rawResults = self::queryByConditions($rawSelectConditions)->get();
// =========================================================================
// 2. RETRIEVAL & ADVANCED FILTERING OPERATIONS / 2. 取得と高度なフィルタリング操作
// =========================================================================
$fullFilters = [
// Standard Condition: WHERE status = 1
'status' => 1,
// Raw WHERE Condition: WHERE DATE(created_at) = CURDATE()
'__whereRaw' => 'DATE(created_at) = CURDATE()',
// Advanced Condition (BETWEEN): WHERE price BETWEEN 100 AND 500
'price' => ['between' => [100, 500]],
// Special Control (Joins): Join 'profiles' table (Example Structure)
'__joins' => [
[
'type' => 'left',
'table' => 'profiles',
'localKey' => 'users.id',
'operator' => '=',
'foreignKey' => 'profiles.user_id',
]
],
// Special Control (Eager Loading): Eager load 'profile' and 'tags'
'__with' => ['profile', 'tags'],
// Special Control (Order and Pagination)
'__orderBy' => [
'price' => 'desc'
],
'__cursorPaginate' => 15,
];
// $paginatedResults = self::queryByConditions($fullFilters);
// $records = $paginatedResults->items(); // Get records from paginator
}
}
Step 3: Add Trait to User Model
Edit app/Models/User.php:
<?php
namespace App\Models;
use App\Traits\HasQueryBuilder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
use HasFactory, Notifiable, HasQueryBuilder;
protected $fillable = ['name', 'email', 'password'];
protected $hidden = ['password', 'remember_token'];
}
Step 4: Create a UserService
Create app/Services/UserService.php:
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
class UserService
{
public function getUsers(array $filters = [])
{
return User::queryByConditions($filters);
}
public function getUserById(int $id)
{
return User::getById($id);
}
public function createUser(array $data)
{
if (isset($data['password'])) {
$data['password'] = Hash::make($data['password']);
}
return User::createRecord($data);
}
public function updateUser(int $id, array $data): bool
{
if (isset($data['password'])) {
$data['password'] = Hash::make($data['password']);
}
return User::updateById($id, $data);
}
public function deleteUser(int $id): bool
{
return User::deleteById($id);
}
}
Step 5: Create API Controller
Generate controller:
php artisan make:controller Api/UserController --api
Edit app/Http/Controllers/Api/UserController.php:
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\UserService;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class UserController extends Controller
{
protected UserService $userService;
public function __construct(UserService $userService)
{
$this->userService = $userService;
}
public function index(Request $request)
{
return response()->json($this->userService->getUsers($request->all()));
}
public function show($id)
{
$user = $this->userService->getUserById($id);
if (!$user) return response()->json(['message' => 'User not found'], 404);
return response()->json($user);
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users,email',
'password' => 'required|string|min:6',
]);
return response()->json($this->userService->createUser($validated), 201);
}
public function update(Request $request, $id)
{
$user = $this->userService->getUserById($id);
if (!$user) return response()->json(['message' => 'User not found'], 404);
$validated = $request->validate([
'name' => 'sometimes|string|max:255',
'email' => ['sometimes', 'email', Rule::unique('users')->ignore($id)],
'password' => 'sometimes|string|min:6',
]);
$updated = $this->userService->updateUser($id, $validated);
if ($updated) {
return response()->json($this->userService->getUserById($id));
}
return response()->json(['message' => 'Update failed'], 400);
}
public function destroy($id)
{
$deleted = $this->userService->deleteUser($id);
if ($deleted) return response()->json(['message' => 'Deleted successfully']);
return response()->json(['message' => 'User not found'], 404);
}
}
Step 6: Define API Routes
Edit routes/api.php:
use App\Http\Controllers\Api\UserController;
Route::apiResource('users', UserController::class);
Step 7: Test Your API
Run the server:
php artisan serve
You can now test:
-
GET /api/userswith query params like?name=John&__paginate=10&__sortBy=email&__order=desc GET /api/users/{id}-
POST /api/userswith JSON body{ "name": "John", "email": "john@example.com", "password": "secret" } -
PUT /api/users/{id}to update user fields DELETE /api/users/{id}
Conclusion
You built a Laravel 12 API using a reusable HasQueryBuilder trait that simplifies querying with filters, sorting, and pagination.
Using the Route → Controller → Service → Model pattern, the code is clean, maintainable, and scalable.