Intermediate · 75 MINUTES · VERSION 1
Laravel owned JSON endpoints and security tests
Verify real saved data, server-owned identity, authorization, selected fields and two-account failure tests.
Reviewed 23 Oct 2026Your setup may differ. Windows, Ubuntu, and optional tool bundles →
What you will learn
- Separate authentication, authorization, validation and response selection.
- Use the authenticated account as owner rather than accepting a browser user ID.
- Verify real saved rows, rejected input and cross-user access with feature tests.
Theory: identity is not permission
Authentication answers who is signed in. Authorization answers whether that identity may perform this action on this resource. Validation checks accepted input shape and values; valid input does not grant permission. Response selection decides which fields leave the server.
This lab uses Laravel web-session authentication and verified accounts for JSON responses. It is not a complete token-authenticated public API. The read query scopes to the current user before looking up an ID, so another account’s ID returns 404. A Gate also expresses the owner rule explicitly. Neither a hidden button nor an unguessable ID replaces a server-side check.
Creation selects user_id from the current authenticated account and accepts only title from validated input. A forged user_id/is_admin field cannot choose the owner or create another field. The returned JSON has selected resource fields, not the whole database row.
Worked example: isolated routes and a real saved record
Required: Laravel 13, PHP 8.4+, MySQL 8.4, framework session authentication/email verification and the normal User factory. Use a new personal practice app with an explicitly separate MySQL testing database whose name ends in _test. Verify the exact database and .env.testing/phpunit settings before running. Never point this test at company or learner data. Laragon/XAMPP are optional; the concepts are common.
Copy the first two files into content/labs in that practice app. Put StudyOwnedItemsTest.php in tests/Feature. The test itself registers teaching routes and creates a uniquely named table only in the testing app. The schema/routes files return registration functions; the table-name parameter is a trusted internal test setting, never request input. Do not add them to this learning website’s production routes.
owned_items_schema.php
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return function (string $table = 'practice_owned_items'): void {
Schema::create($table, function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title', 80);
});
};
owned_items_routes.php
<?php
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Route;
return function (string $table = 'practice_owned_items'): void {
Gate::define('view-practice-item', fn (User $user, object $item): bool => $user->id === (int) $item->user_id);
Route::middleware(['auth', 'verified'])->group(function () use ($table): void {
Route::post('/study/owned-items', function (Request $request) use ($table) {
$input = $request->validate(['title' => ['required', 'string', 'max:80']]);
$id = DB::table($table)->insertGetId(['user_id' => $request->user()->id, 'title' => $input['title']]);
return response()->json(['id' => $id, 'title' => $input['title'], 'saved' => true], 201)
->header('Cache-Control', 'private, no-store');
})->middleware('throttle:20,1');
Route::get('/study/owned-items/{id}', function (Request $request, string $id) use ($table) {
$item = DB::table($table)->where('user_id', $request->user()->id)->where('id', $id)->first();
abort_if($item === null, 404);
Gate::authorize('view-practice-item', $item);
return response()->json(['id' => $item->id, 'title' => $item->title])
->header('Cache-Control', 'private, no-store');
})->whereNumber('id');
});
};
StudyOwnedItemsTest.php
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class StudyOwnedItemsTest extends TestCase
{
protected string $fixtureTable = 'practice_owned_items';
protected array $ownedUsers = [];
private bool $createdFixture = false;
protected function setUp(): void
{
parent::setUp();
$this->assertTrue(app()->environment('testing'));
$this->assertSame('mysql', DB::connection()->getDriverName());
$this->assertStringEndsWith('_test', DB::connection()->getDatabaseName());
$this->artisan('migrate', ['--force' => true])->assertSuccessful();
$this->fixtureTable = 'labapi_'.bin2hex(random_bytes(5));
$this->assertFalse(Schema::hasTable($this->fixtureTable));
(require base_path('content/labs/owned_items_schema.php'))($this->fixtureTable);
$this->createdFixture = true;
Route::middleware('web')->group(function (): void {
(require base_path('content/labs/owned_items_routes.php'))($this->fixtureTable);
});
}
private function person(): User
{
$user = User::factory()->create(['email_verified_at' => now()]);
$this->ownedUsers[] = $user->id;
return $user;
}
protected function tearDown(): void
{
try {
if ($this->createdFixture) {
$this->assertStringEndsWith('_test', DB::connection()->getDatabaseName());
$this->assertMatchesRegularExpression('/^labapi_[a-f0-9]{10}$/', $this->fixtureTable);
Schema::drop($this->fixtureTable);
$this->assertFalse(Schema::hasTable($this->fixtureTable));
}
if ($this->ownedUsers !== []) {
User::whereIn('id', $this->ownedUsers)->delete();
}
} finally {
parent::tearDown();
}
}
public function test_guests_cannot_create_or_read_items(): void
{
$this->getJson('/study/owned-items/1')->assertUnauthorized();
$this->postJson('/study/owned-items', ['title' => 'Read'])->assertUnauthorized();
$this->assertSame(0, DB::table($this->fixtureTable)->count());
}
public function test_owner_comes_from_the_session_and_other_users_cannot_read_it(): void
{
$alice = $this->person();
$bob = $this->person();
$response = $this->actingAs($alice)->postJson('/study/owned-items', ['title' => 'Read tests', 'user_id' => $bob->id, 'is_admin' => true])
->assertCreated()->assertJsonPath('saved', true)->assertJsonMissingPath('user_id');
$id = $response->json('id');
$item = DB::table($this->fixtureTable)->where('id', $id)->first();
$this->assertSame($alice->id, (int) $item->user_id);
$this->getJson('/study/owned-items/'.$id)->assertExactJson(['id' => $id, 'title' => 'Read tests'])
->assertHeader('Cache-Control', 'no-store, private');
$this->assertTrue(Gate::forUser($alice)->allows('view-practice-item', $item));
$this->assertTrue(Gate::forUser($bob)->denies('view-practice-item', $item));
$this->actingAs($bob)->getJson('/study/owned-items/'.$id)->assertNotFound();
$this->assertSame(1, DB::table($this->fixtureTable)->count());
}
public function test_invalid_fields_do_not_save_and_unverified_accounts_are_denied(): void
{
$user = $this->person();
$this->actingAs($user);
foreach (['', ' ', ['title'], str_repeat('x', 81)] as $invalid) {
$this->postJson('/study/owned-items', ['title' => $invalid])->assertUnprocessable()->assertJsonValidationErrors('title');
$this->assertSame(0, DB::table($this->fixtureTable)->count());
}
$user->forceFill(['email_verified_at' => null])->save();
$this->actingAs($user->fresh())->postJson('/study/owned-items', ['title' => 'Read'])->assertForbidden();
$this->assertSame(0, DB::table($this->fixtureTable)->count());
}
}
Run and interpret the evidence
Windows PowerShell, in your isolated practice Laravel project:
php artisan test --compact --filter=StudyOwnedItemsTest
Ubuntu Bash, in your isolated practice Laravel project:
php artisan test --compact --filter=StudyOwnedItemsTest
The example guards environment, driver and test database name before migration. A naming guard is not a substitute for checking your real connection; this website adds an exact dedicated-database guard in its own TestCase. Fixture cleanup drops only the generated table and removes only the users created by the test. No production schema is created.
Guests receive JSON 401, invalid titles receive 422, unverified accounts receive 403, an owned create returns 201 and saved=true after a real insert, and another account’s read receives 404. Successful reads use private/no-store headers. Unlike the earlier preview lesson, this endpoint actually persists a row.
The trusted fixture was executed on Windows against isolated MySQL. Ubuntu/browser execution and delivered email are not verified. HTTP feature tests bypass normal browser CSRF checks in the testing environment; they do not prove CSRF handling or the actual login UI works.
Guided lab
- Predict responses for guest, unverified user, owner and another user before running.
- Inspect the saved user_id after posting a forged owner field. It must come from the authenticated session.
- Confirm invalid title types, empty/whitespace text and excessive length create no row. Framework string max here is character-based, unlike the earlier byte-bound CLI exercises.
- Check the exact response fields and private cache header; do not expose user IDs just because they exist in storage.
- In a disposable copy, remove owner scoping and the Gate check and confirm the cross-user test fails. Restore both. Record failures as evidence, not as a product feature.
Boundaries this example does not solve
Repeated valid POSTs create distinct items. A throttle is not idempotency. A later webhook or payment-like operation needs explicit request identity and duplicate-effect rules. This exercise performs one insert, not a multi-write transaction demonstration.
Session-based JSON endpoints still require a correct CSRF/session/cookie design outside tests. API token authentication, CORS, pagination, updates/deletes, role sharing, audit requirements and production rate-limit/cache topology need separate designs. Client-side escaping and HTML safety remain necessary if a UI later renders title. Do not call a small passing fixture a complete secure API.
Independent exercise
Add an owned PATCH endpoint in a separate practice copy. Resolve the current user’s row on the server, authorize the update, validate title and update only that field. Return selected resource fields.
Test owned success, another account’s ID, missing row, invalid data with unchanged stored title, and forged user_id. Test the actual row after each failed action. Hint: authorization applies to the target resource; accepting a valid title is not enough. Do not assume a repeated request is handled merely because it returns 200.
Common mistakes and interview
Avoid trusting posted ownership, checking only login, passing all request fields to storage, exposing complete rows, testing only the happy path, assuming a 201 preview actually saved, and claiming test-mode HTTP proves browser CSRF protection.
Interview: “How do you test a private endpoint?” Explain identity, resource lookup scope, Gate/policy, accepted fields, selected response, persistence and two-account negative tests. Name what requires a real browser or production-like environment.
English practice and glossary
Authentication: checking identity. Authorization: deciding permission. Ownership scope: limiting a query to an account’s resources. Gate: a framework authorization rule. Validation: checking accepted input. Selected fields: explicitly chosen stored/returned data.
Say: “The user is signed in, but cannot read __ because __. I posted a forged __ and verified __ in storage.” Save dummy evidence in the practice journal.
Recap and next step
Use framework identity and authorization plus explicit input/output boundaries. Verify real saved data and failed cross-user actions. Next learn Eloquent relationships, eager loading and resource pagination while preserving the same ownership rules.
Reveal exercise hints and self-check answers
The authenticated account supplies ownership; posted IDs are ignored. Read queries scope before lookup and the Gate encodes ownership. Invalid fields create no row. The real insert justifies saved=true/201; an in-memory preview would not. No browser CSRF, email delivery, token API or duplicate-submit guarantee is implied.
Exercise outline: use the current owner in lookup, authorize the intended action, accept only a validated title, update only title and assert both response and stored row. Keep owner identity fixed and preserve data after rejected requests.