Bekzod Erkinov
Listen to Article
Loading...Table of contents · 8 sections
Laravel API Resource Controller, Validation & JSON Resources (REST API Part 2)
A search box that sends ?q=&sort=&per_page= gets a 422 from a Laravel list endpoint, and a POST without Accept: application/json gets a 302 redirect (failed validation) or a 500 HTML page (no token) instead of JSON. This post builds a posts endpoint with an apiResource controller, Form Request validation and JSON resources, and shows the one rule and the two bootstrap/app.php settings that fix those failures.
- Every rule in a list endpoint's Form Request needs
nullable. Without it, the empty-params test below returned 422 on my machine. - A bare API request needs both
redirectGuestsToandshouldRenderJsonWhen. Each one alone still left a failing test.
Part 1: Part 1 covers API design, versioning and Sanctum authentication. This post assumes a /api/v1 route prefix and Sanctum, and the setup block below builds both from scratch. Tests use Sanctum::actingAs, so no login endpoint is needed.
Version: Laravel 12.69.3, PHP 8.4.24, SQLite. All output below is from running the code in this post on that stack.
Setup from scratch: commands, migrations, models, factories, policy
composer create-project laravel/laravel:^12.0 api && cd api
php artisan install:api # installs Sanctum, creates routes/api.php
mkdir app/Enums
php artisan make:model Category -mf
php artisan make:model Post -mf
php artisan make:controller Api/V1/PostController --api --model=Post --requests
php artisan make:resource PostResource
php artisan make:resource PostCollection
php artisan make:resource CategoryResource
php artisan make:request ListPostsRequest
php artisan make:policy PostPolicy --model=Post
install:api prints Please add the [Laravel\Sanctum\HasApiTokens] trait to your User model. In app/Models/User.php:
use Illuminate\Database\Eloquent\Relations\HasMany;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
// ...the generated $fillable, $hidden and casts() stay as they are
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
}
Migrations (the create_categories_table and create_posts_table files that make:model -mf generated; run php artisan migrate afterwards):
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
});
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('category_id')->nullable()->constrained()->nullOnDelete();
$table->string('title');
$table->string('slug')->unique();
$table->text('body');
$table->string('status')->default('draft')->index();
$table->timestamp('published_at')->nullable();
$table->timestamps();
});
// app/Enums/PostStatus.php
namespace App\Enums;
enum PostStatus: string
{
case Draft = 'draft';
case Published = 'published';
}
// app/Models/Category.php: the class body
use HasFactory;
protected $fillable = ['name'];
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
// app/Models/Post.php
namespace App\Models;
use App\Enums\PostStatus;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Post extends Model
{
use HasFactory;
protected $fillable = ['title', 'slug', 'body', 'status', 'category_id', 'published_at'];
// create() does not re-read the row, so the migration default is not on the model
protected $attributes = ['status' => 'draft'];
protected function casts(): array
{
return ['status' => PostStatus::class, 'published_at' => 'datetime'];
}
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function scopePublished(Builder $query): Builder
{
return $query->where('status', PostStatus::Published);
}
public function scopeFilter(Builder $query, array $f): Builder
{
// isset(), not truthiness: ?q=0 is a real search term
return $query
->when(isset($f['category_id']), fn ($q) => $q->where('category_id', $f['category_id']))
->when(isset($f['q']), fn ($q) => $q->whereRaw(
"title like ? escape '!'",
['%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $f['q']).'%']
));
}
}
// factories: definition() bodies
// CategoryFactory
return ['name' => fake()->unique()->word()];
// PostFactory
$title = fake()->unique()->sentence(4);
return [
'user_id' => User::factory(),
'category_id' => Category::factory(),
'title' => $title,
'slug' => Str::slug($title).'-'.fake()->unique()->numberBetween(1, 999999),
'body' => fake()->paragraph(),
'status' => 'published',
'published_at' => now(),
];
// app/Http/Resources/CategoryResource.php: toArray()
return ['id' => $this->id, 'name' => $this->name];
// app/Policies/PostPolicy.php: replace the generated methods with these two
public function update(User $user, Post $post): bool
{
return $user->id === $post->user_id;
}
public function delete(User $user, Post $post): bool
{
return $this->update($user, $post);
}
Delete the two skeleton ExampleTest files so the test output below is only this post's tests.
Routes: public reads, authenticated writes
According to the Laravel controllers docs, Route::apiResource registers the API routes without create/edit. I register it twice so reads are public and writes need a token:
// routes/api.php
Route::prefix('v1')->group(function () {
Route::apiResource('posts', PostController::class)->only(['index', 'show']);
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('posts', PostController::class)->except(['index', 'show']);
});
});
php artisan route:list --path=api --except-vendor printed:
GET|HEAD api/v1/posts ............................................. posts.index › Api\V1\PostController@index
POST api/v1/posts ............................................. posts.store › Api\V1\PostController@store
GET|HEAD api/v1/posts/{post} ........................................ posts.show › Api\V1\PostController@show
PUT|PATCH api/v1/posts/{post} .................................... posts.update › Api\V1\PostController@update
DELETE api/v1/posts/{post} .................................. posts.destroy › Api\V1\PostController@destroy
Showing [5] routes
Form Requests for writes
// app/Http/Requests/StorePostRequest.php
use App\Enums\PostStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class StorePostRequest extends FormRequest
{
public function authorize(): bool
{
return true; // auth:sanctum on the route group already gates this
}
protected function prepareForValidation(): void
{
if (! $this->filled('slug') && $this->filled('title')) {
$this->merge(['slug' => Str::slug($this->input('title'))]);
}
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique('posts', 'slug')],
'body' => ['required', 'string'],
'status' => ['sometimes', Rule::enum(PostStatus::class)],
'category_id' => ['nullable', 'integer', 'exists:categories,id'],
];
}
}
UpdatePostRequest differs in three ways: fields become sometimes so PATCH can send one field, the slug rule ignores the post being edited, and authorize() asks the policy. Returning false gives a 403 before the controller runs.
// app/Http/Requests/UpdatePostRequest.php
use App\Enums\PostStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdatePostRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('update', $this->route('post'));
}
public function rules(): array
{
return [
'title' => ['sometimes', 'string', 'max:255'],
'slug' => ['sometimes', 'string', 'max:255', 'alpha_dash',
Rule::unique('posts', 'slug')->ignore($this->route('post'))],
'body' => ['sometimes', 'string'],
'status' => ['sometimes', Rule::enum(PostStatus::class)],
'category_id' => ['nullable', 'integer', 'exists:categories,id'],
];
}
}
Why every list rule has nullable
The list endpoint takes user input too (per_page, sort, include) and those values reach orderBy, with and paginate, so it gets its own Form Request. make:request generates authorize() returning false, which would give a 403 on every list call, so change it:
// app/Http/Requests/ListPostsRequest.php
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ListPostsRequest extends FormRequest
{
public function authorize(): bool
{
return true; // public endpoint
}
public function rules(): array
{
return [
'category_id' => ['nullable', 'integer'],
'q' => ['nullable', 'string', 'max:100'],
'sort' => ['nullable', Rule::in(['created_at', '-created_at', 'title', '-title'])],
'per_page' => ['nullable', 'integer', 'between:1,50'],
'include' => ['nullable', 'string', 'regex:/^(category|author)(,(category|author))*$/'],
];
}
}
All tests in this post live in one class. These are its imports, which the later snippets rely on:
// tests/Feature/PostApiTest.php
namespace Tests\Feature;
use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class PostApiTest extends TestCase
{
use RefreshDatabase;
public function test_empty_query_params_are_ignored(): void
{
Post::factory()->count(3)->create();
$this->getJson('/api/v1/posts?q=&sort=&per_page=&include=&category_id=')
->assertOk()
->assertJsonCount(3, 'data');
}
With nullable removed from ListPostsRequest, that test failed:
Expected response status code [200] but received 422.
{
"message": "The category id field must be an integer. (and 6 more errors)",
"errors": {
"category_id": [
"The category id field must be an integer."
],
"q": [
"The q field must be a string."
],
"sort": [
"The selected sort is invalid."
],
The Laravel request docs say the global middleware stack will "convert any empty string fields to null". So ?q= reaches the validator as null, integer/string/in/regex fail on it, and a blank search box is a 422. With nullable the test passes and the controller treats null as "not provided".
The same null matters in the model scope. My first scopeFilter used when($f['q'] ?? null, ...), and when() treats the string '0' as falsy, so ?q=0 was silently ignored:
public function test_zero_is_a_real_search_term(): void
{
Post::factory()->create(['title' => 'Top 10 tips']);
Post::factory()->create(['title' => 'Other']);
$this->getJson('/api/v1/posts?q=0')->assertOk()->assertJsonCount(1, 'data');
}
On the truthiness version it failed with Failed asserting that actual size 2 matches expected size 1.; it passes with isset() as in the setup block.
q goes into a LIKE, where % and _ are wildcards. The scope declares escape '!' (the SQLite LIKE ... ESCAPE syntax) and escapes !, % and _ first. I ran this on SQLite only and did not test the clause on MySQL or PostgreSQL.
public function test_like_wildcards_in_q_are_literal(): void
{
Post::factory()->create(['title' => '100% real']);
Post::factory()->create(['title' => 'Plain title']);
$this->getJson('/api/v1/posts?q=%25')->assertOk()->assertJsonCount(1, 'data'); // %25 is a literal %
}
The include regex is also the relation whitelist:
public function test_include_is_a_relation_whitelist(): void
{
$this->getJson('/api/v1/posts?include=author.email,password')
->assertStatus(422)
->assertJsonValidationErrors('include');
}
Against php -S, GET /api/v1/posts?include=author.email,password returned:
HTTP/1.1 422 Unprocessable Content
{"message":"The include field format is invalid.","errors":{"include":["The include field format is invalid."]}}
Decision rule: if a query parameter reaches orderBy, with or paginate, it gets a whitelist or a numeric cap in a Form Request. The per_page cap of 50 is my choice, not a Laravel default.
Laravel API returns 302 or 500 instead of JSON: shouldRenderJsonWhen and redirectGuestsTo
Two bare requests (no Accept: application/json) go wrong. I reproduced them with php -S and with feature tests that call post() instead of postJson(). With the default bootstrap/app.php:
curl -i -X POST http://127.0.0.1:8099/api/v1/posts -H "Authorization: Bearer $TOKEN" -d 'status=nope'
# HTTP/1.1 302 Found
# Location: http://127.0.0.1:8099
curl -i -X POST http://127.0.0.1:8099/api/v1/posts -d 'title=x'
# HTTP/1.1 500 Internal Server Error
# Content-Type: text/html; charset=utf-8
The validation docs say a failed traditional request is redirected back and an XHR request gets JSON. The 500 test output was Route [login] not defined. (below).
public function test_validation_failure_is_json_even_without_accept_header(): void
{
Sanctum::actingAs(User::factory()->create());
$this->post('/api/v1/posts', ['status' => 'nope'])->assertStatus(422);
}
public function test_unauthenticated_write_without_accept_header_is_json_401(): void
{
$this->post('/api/v1/posts', ['title' => 'x'])->assertStatus(401);
}
With default settings they failed:
Expected response status code [422] but received 302.
Expected response status code [401] but received 500.
Symfony\Component\Routing\Exception\RouteNotFoundException: Route [login] not defined.
The fix: both settings, and what each one does
// bootstrap/app.php, inside Application::configure(...)
->withMiddleware(function (Middleware $middleware): void {
// no redirect target for API paths: the guest gets a 401 instead of route('login')
$middleware->redirectGuestsTo(
fn ($request) => $request->is('api/*') ? null : route('login')
);
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn ($request, $e) => $request->is('api/*') || $request->expectsJson()
);
})
The error handling docs describe shouldRenderJsonWhen as the hook for choosing HTML or JSON exception responses, and the authentication docs show redirectGuestsTo with a path or a closure. Neither page mentions validation or returning null, so I ran the whole test class with each setting removed in turn:
bootstrap/app.php |
Result |
|---|---|
| both settings | 10 passed (36 assertions) |
shouldRenderJsonWhen only |
1 failed, 9 passed: the 401 test got a 500, Route [login] not defined |
redirectGuestsTo only |
2 failed, 8 passed: the validation test got a 302 and the 401 test got a 500, Route [login] not defined |
| neither | the same two tests fail as in the previous section |
Each setting is needed for a different reason. The Route [login] not defined trace comes from a different call site in each single-setting run, which I read against framework v12.69.3:
- Default callback, called by the middleware. In the
shouldRenderJsonWhen-only run the trace goesAuthenticate->unauthenticated()→Authenticate->redirectTo()→ApplicationBuilder.phpline 278, wherewithMiddleware()installs the defaultredirectGuestsTo(fn () => route('login')).Authenticate::unauthenticated()calls that callback whenever$request->expectsJson()is false, which is what a bare request is. YourredirectGuestsToclosure replaces it. - Fallback in the exception handler. In the
redirectGuestsTo-only run the trace ends inHandler::unauthenticated(): whenshouldReturnJson()is false it runsredirect()->guest($exception->redirectTo($request) ?? route('login')), so anulltarget still falls back toroute('login'). WhenshouldRenderJsonWhenreturns true it takes the JSON branch and answers 401. - Validation follows the same switch.
convertValidationExceptionToResponse()picksinvalidJson()or the redirectinginvalid()through the sameshouldReturnJson(), which calls your callback when one is set and$request->expectsJson()otherwise. That is whyshouldRenderJsonWhenfixes the 302.
Where Sanctum fits. Sanctum does not render the 401 itself. auth:sanctum is Laravel's standard Authenticate middleware given the sanctum guard (the stack trace shows Authenticate->handle(..., 'sanctum')), so the redirect and rendering logic above is Laravel's, not Sanctum's. The Sanctum docs cover protecting routes.
Laravel 13. I did not run this on Laravel 13. Its docs still document redirectGuestsTo and shouldRenderJsonWhen. To check your own 13 app, run the two bare-request tests above once with the settings removed (they should fail) and once with them in place.
With both settings in place a bare curl -i -X POST http://127.0.0.1:8099/api/v1/posts -d 'title=x' returned:
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{"message":"Unauthenticated."}
Trade-off: every exception on api/* renders as JSON. With APP_DEBUG=true, GET /api/v1/posts/9999 returned a body with the keys message, exception, file, line and trace; with APP_DEBUG=false the only key was message. Keep debug off anywhere public.
Resources: PostCollection or PostResource::collection?
// app/Http/Resources/PostResource.php
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'body' => $this->body,
'status' => $this->status,
'published_at' => $this->published_at?->toIso8601String(),
'category' => new CategoryResource($this->whenLoaded('category')),
'author' => $this->whenLoaded('author', fn () => [
'id' => $this->author->id,
'name' => $this->author->name,
]),
'links' => ['self' => route('posts.show', $this->resource)],
];
}
whenLoaded (see the API Resources docs) drops a relation key unless the controller eager-loaded it, so the resource never lazy-loads by itself.
On 12.69.3, PostResource::collection(Post::paginate(1)) returned the meta keys current_page,from,last_page,links,path,per_page,to,total, where meta.links is an array of pager-button objects such as {"url":null,"label":"« Previous","page":null,"active":false}. With new PostCollection(...) the keys were current_page,from,last_page,path,per_page,to,total.
Decision rule: use PostResource::collection(...) if your client renders pager buttons from meta.links. Use PostCollection if clients follow the top-level links (first, last, prev, next) and you do not need the button array. The test pins the second choice:
public function test_pager_buttons_are_not_in_meta(): void
{
Post::factory()->count(3)->create();
$this->getJson('/api/v1/posts?per_page=2')
->assertOk()
->assertJsonMissingPath('meta.links')
->assertJsonStructure(['links' => ['first', 'last', 'prev', 'next']]);
}
// app/Http/Resources/PostCollection.php
class PostCollection extends ResourceCollection
{
public $collects = PostResource::class;
public function paginationInformation($request, $paginated, $default)
{
unset($default['meta']['links']);
return $default;
}
}
GET /api/v1/posts?per_page=2&include=category on 3 seeded published rows returned (first post shown, body is faker output):
{
"data": [
{
"id": 1,
"title": "Ut reprehenderit veniam temporibus.",
"slug": "ut-reprehenderit-veniam-temporibus-934090",
"body": "Adipisci accusamus magnam sint quos eaque vitae. ...",
"status": "published",
"published_at": "2026-10-10T14:13:37+00:00",
"category": { "id": 1, "name": "dolores" },
"links": { "self": "http://127.0.0.1:8099/api/v1/posts/1" }
},
{ "id": 2, "...": "second post, same shape" }
],
"links": {
"first": "http://127.0.0.1:8099/api/v1/posts?per_page=2&include=category&page=1",
"last": "http://127.0.0.1:8099/api/v1/posts?per_page=2&include=category&page=2",
"prev": null,
"next": "http://127.0.0.1:8099/api/v1/posts?per_page=2&include=category&page=2"
},
"meta": {
"current_page": 1,
"from": 1,
"last_page": 2,
"path": "http://127.0.0.1:8099/api/v1/posts",
"per_page": 2,
"to": 2,
"total": 3
}
}
The controller: filtering, pagination, private drafts
// app/Http/Controllers/Api/V1/PostController.php
use App\Enums\PostStatus;
use App\Http\Requests\ListPostsRequest;
use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest;
use App\Http\Resources\PostCollection;
use App\Http\Resources\PostResource;
use App\Models\Post;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Gate;
public function index(ListPostsRequest $request)
{
$v = $request->validated();
$sort = $v['sort'] ?? '-created_at';
$posts = Post::query()
->published() // public reads never see drafts
->with(array_filter(explode(',', $v['include'] ?? '')))
->filter($v)
->orderBy(ltrim($sort, '-'), str_starts_with($sort, '-') ? 'desc' : 'asc')
->orderBy('id') // tie-breaker so pages are stable
->paginate($v['per_page'] ?? 15)
->withQueryString();
return new PostCollection($posts);
}
public function store(StorePostRequest $request): JsonResponse
{
$post = $request->user()->posts()->create($request->validated());
return (new PostResource($post->load('category')))
->response()
->setStatusCode(201)
->header('Location', route('posts.show', $post));
}
public function show(Post $post): PostResource
{
abort_unless($post->status === PostStatus::Published, 404);
return new PostResource($post->load('category', 'author'));
}
public function update(UpdatePostRequest $request, Post $post): PostResource
{
$post->update($request->validated());
return new PostResource($post->load('category'));
}
public function destroy(Post $post): Response
{
Gate::authorize('delete', $post);
$post->delete();
return response()->noContent();
}
store returns 201 with a Location header (RFC 9110 on 201 Created).
Drafts are private. index and show have no auth, so both serve only published posts and status is not a list filter:
public function test_drafts_are_not_public(): void
{
Post::factory()->create();
$draft = Post::factory()->create(['status' => 'draft']);
$this->getJson('/api/v1/posts')->assertOk()->assertJsonCount(1, 'data');
$this->getJson('/api/v1/posts?status=draft')->assertOk()->assertJsonCount(1, 'data'); // unknown params are ignored
$this->getJson("/api/v1/posts/{$draft->id}")->assertNotFound();
}
The policy. I did not register PostPolicy anywhere, and the 403 assertions below pass. The policy discovery docs say Laravel finds policies that follow the naming convention. Gate::authorize throws an AuthorizationException that becomes a 403 (docs).
public function test_update_and_delete_are_owner_only(): void
{
$post = Post::factory()->create();
Sanctum::actingAs(User::factory()->create());
$this->patchJson("/api/v1/posts/{$post->id}", ['title' => 'Hijacked'])->assertForbidden();
$this->deleteJson("/api/v1/posts/{$post->id}")->assertForbidden();
Sanctum::actingAs($post->author);
$this->patchJson("/api/v1/posts/{$post->id}", ['slug' => $post->slug])->assertOk();
$this->deleteJson("/api/v1/posts/{$post->id}")->assertNoContent();
}
Testing: query count and the full run
The query-count test runs the list with and without include for 10 posts and again for 30, and asserts the counts are equal. Laravel's docs call the lazy-loading problem this guards against N+1 queries:
public function test_include_costs_a_fixed_number_of_queries(): void
{
$count = function (int $posts): array {
Post::query()->delete();
Post::factory()->count($posts)->create();
DB::flushQueryLog();
DB::enableQueryLog();
$this->getJson('/api/v1/posts?include=category,author&per_page=50')->assertOk();
$with = count(DB::getQueryLog());
DB::flushQueryLog();
$this->getJson('/api/v1/posts?per_page=50')->assertOk();
$without = count(DB::getQueryLog());
DB::disableQueryLog();
return [$with, $without];
};
$this->assertSame($count(10), $count(30));
$this->assertSame([4, 2], $count(10)); // 4 queries with include, 2 without
}
}
Flush the query log before you count: without flushQueryLog() the earlier call's queries are still in the log and inflate the next count.
That is all ten tests in PostApiTest. php artisan test, with both bootstrap/app.php settings in place:
✓ empty query params are ignored
✓ zero is a real search term
✓ like wildcards in q are literal
✓ include is a relation whitelist
✓ drafts are not public
✓ pager buttons are not in meta
✓ update and delete are owner only
✓ validation failure is json even without accept header
✓ unauthenticated write without accept header is json 401
✓ include costs a fixed number of queries
Tests: 10 passed (36 assertions)
Next steps
- Check your own API now:
curl -i -X POST https://your-host/api/v1/your-resource -d 'x=1'with noAcceptheader and no token. A 302 or 500 means you need the two settings above; a JSON 401 means you are fine. - Close the draft gap this post leaves open: authors cannot read their own drafts, and
published_atstaysnullunless the client sends it. Start with a failing test (a sketch, not run), then add an authenticated read route scoped to$request->user()->posts():
public function test_author_can_list_own_drafts(): void
{
$user = User::factory()->create();
Post::factory()->for($user, 'author')->create(['status' => 'draft']);
Post::factory()->for($user, 'author')->create();
Sanctum::actingAs($user);
$this->getJson('/api/v1/me/posts')->assertOk()->assertJsonCount(2, 'data');
}
- For your next model, write the list Form Request first, with
nullableon every rule, and copy the query-count test onto your busiest list endpoint. - Next: Part 3, Rate Limiting, Caching & Production Hardening.
Keep reading
Building a Modern SaaS Application with Laravel - Part 1: Multi-Tenancy Architecture & Database Foundations
58 min · 1,076 views
Operating SystemsAdvanced Laravel Tutorial: Queues, Events and Pest Tests
22 min · 248 views
PerformanceComplete Solution: Building a Secure E-commerce Website with Stripe and Laravel
28 min · 231 views
Bekzod Erkinov
AuthorFounder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.
Get the AI-Assisted Developer's Field Guide
The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.
Comments (0)
Please log in to leave a comment.
Log InRelated Articles
Building a REST API with Laravel - Part 1: API Design, Versioning & Sanctum Authentication
May 10, 2026
ChatGPT vs Claude: Real Performance Benchmarks From Production Testing
Apr 22, 2026
Building a Production-Grade E-Commerce Platform with Laravel 12 - Part 2: Product Catalog & Cart with Database Design
May 12, 2026