From 9918127e4744f49aa199a3c60b0124448acc4fc6 Mon Sep 17 00:00:00 2001 From: Fady Mondy Date: Tue, 15 Sep 2026 10:21:55 +0300 Subject: [PATCH 1/2] Secure the inline column update routes The resource and relation manager column update routes loaded records without tenant scoping, skipped update authorization, and wrote any requested attribute (the relation route with no check at all), so any panel user could change arbitrary attributes of records they may not edit. Both routes now go through ColumnStateController, which only writes an editable, non-disabled, non-relationship column of the relevant table, loads records through the resource's tenant-scoped query, enforces canUpdate()/policies (plus the relation manager's canEdit()), validates the value with the column's rules and writes only that attribute. Releases of laravilt/tables without EditableColumn fall back to allowing only ToggleColumn with a boolean value. Co-Authored-By: Claude Opus 5 (1M context) --- .../Controllers/ColumnStateController.php | 253 +++++++++++++ src/PanelServiceProvider.php | 105 +----- tests/Feature/ColumnUpdateRouteTest.php | 354 ++++++++++++++++++ 3 files changed, 615 insertions(+), 97 deletions(-) create mode 100644 src/Http/Controllers/ColumnStateController.php create mode 100644 tests/Feature/ColumnUpdateRouteTest.php diff --git a/src/Http/Controllers/ColumnStateController.php b/src/Http/Controllers/ColumnStateController.php new file mode 100644 index 0000000..2ca6fc4 --- /dev/null +++ b/src/Http/Controllers/ColumnStateController.php @@ -0,0 +1,253 @@ +resolveColumn($resourceClass::table(new Table), $request); + + $record = $this->resolveOwnerRecord($resourceClass, (string) $request->route('id')); + + abort_unless($this->canUpdateResourceRecord($resourceClass, $record), 403); + + $name = $column->getName(); + $value = $this->validateState($column, $request); + + $this->persist($column, $record, $name, $value); + + if ($request->wantsJson() && ! $request->header('X-Inertia')) { + return response()->json([ + 'success' => true, + 'column' => $name, + 'state' => $record->getAttribute($name), + ]); + } + + return back(); + } + + /** + * PATCH {slug}/{id}/relations/{relationship}/{relationId}/column + * + * @param class-string $resourceClass + */ + public function updateRelationColumn(Request $request, string $resourceClass): mixed + { + $relationship = (string) $request->route('relationship'); + + $relationManagerClass = null; + + foreach ($resourceClass::getRelations() as $rmClass) { + if ($rmClass::getRelationship() === $relationship) { + $relationManagerClass = $rmClass; + break; + } + } + + if (! $relationManagerClass) { + if ($request->wantsJson()) { + return response()->json(['error' => 'Relation manager not found'], 404); + } + + return back()->withErrors(['error' => 'Relation manager not found']); + } + + // The owner record is loaded through the resource query, so tenant scoping applies + $ownerRecord = $this->resolveOwnerRecord($resourceClass, (string) $request->route('id')); + + /** @var RelationManager $relationManager */ + $relationManager = $relationManagerClass::make($ownerRecord); + + $column = $this->resolveColumn($relationManager->table(new Table), $request); + + // The related record must belong to the (scoped) owner record + $relatedRecord = $relationManager->getRelationshipQuery()->findOrFail($request->route('relationId')); + + abort_unless($this->canUpdateRelatedRecord($resourceClass, $relationManager, $ownerRecord, $relatedRecord), 403); + + $name = $column->getName(); + $value = $this->validateState($column, $request); + + $this->persist($column, $relatedRecord, $name, $value); + + if ($request->wantsJson() && ! $request->header('X-Inertia')) { + return response()->json([ + 'success' => true, + 'data' => $relatedRecord, + ]); + } + + return back(); + } + + /** + * Whether the installed laravilt/tables ships the EditableColumn contract (tables > 1.0.x). + */ + protected function supportsEditableColumns(): bool + { + return interface_exists(EditableColumn::class); + } + + /** + * Find the requested column in the table and make sure it may be written inline. + */ + protected function resolveColumn(Table $table, Request $request): object + { + $name = $request->input('column'); + + abort_if(! is_string($name) || $name === '', 422, 'The column field is required.'); + + foreach ($table->getColumns() as $column) { + if ($column->getName() !== $name) { + continue; + } + + // Plain display columns (TextColumn, ...) can never be written through this endpoint + abort_unless($this->isEditableColumn($column), 403, 'This column is not editable.'); + // Relationship paths ("author.name") are display-only: only the record's own attribute is updated + abort_if(str_contains($name, '.'), 403, 'Relationship columns cannot be edited inline.'); + abort_if(method_exists($column, 'isDisabled') && $column->isDisabled(), 403, 'This column is disabled.'); + + return $column; + } + + abort(404, 'Column not found.'); + } + + protected function isEditableColumn(object $column): bool + { + if ($this->supportsEditableColumns()) { + return $column instanceof EditableColumn; + } + + // Legacy tables: ToggleColumn is the only inline-editable column + return $column instanceof ToggleColumn; + } + + /** + * @param class-string $resourceClass + */ + protected function resolveOwnerRecord(string $resourceClass, string $key): Model + { + // getEloquentQuery() applies the resource's tenant scoping + $query = method_exists($resourceClass, 'getEloquentQuery') + ? $resourceClass::getEloquentQuery() + : $resourceClass::getModel()::query(); + + return $query->whereKey($key)->firstOrFail(); + } + + /** + * @param class-string $resourceClass + */ + protected function canUpdateResourceRecord(string $resourceClass, Model $record): bool + { + // Resource authorization honours $usePolicies (policy "update") or the panel's permission system + if (method_exists($resourceClass, 'canUpdate')) { + return (bool) $resourceClass::canUpdate($record); + } + + if (Gate::getPolicyFor($record)) { + return Gate::allows('update', $record); + } + + return auth()->check(); + } + + /** + * Editing a related record inline requires the relation manager to allow editing, the user to be + * allowed to update the owner record, and (when the related model has a policy) to update the related record. + * + * @param class-string $resourceClass + */ + protected function canUpdateRelatedRecord(string $resourceClass, RelationManager $relationManager, Model $ownerRecord, Model $relatedRecord): bool + { + if (method_exists($relationManager, 'canEdit') && ! $relationManager->canEdit()) { + return false; + } + + if (! $this->canUpdateResourceRecord($resourceClass, $ownerRecord)) { + return false; + } + + if (Gate::getPolicyFor($relatedRecord)) { + return Gate::allows('update', $relatedRecord); + } + + return true; + } + + /** + * Validate the incoming value with the column's rules and return it converted for storage. + */ + protected function validateState(object $column, Request $request): mixed + { + $name = $column->getName(); + $label = method_exists($column, 'getLabel') && $column->getLabel() ? $column->getLabel() : $name; + + if ($this->supportsEditableColumns() && $column instanceof EditableColumn) { + $rules = $column->getStateValidationRules(); + } else { + $rules = ['required', 'boolean']; + } + + // Errors are keyed by the column name, which is what the Vue columns read on failure + $validated = Validator::make( + [$name => $request->input('value')], + [$name => $rules], + [], + [$name => $label], + )->validate(); + + $value = $validated[$name] ?? null; + + if ($this->supportsEditableColumns() && $column instanceof EditableColumn) { + return $column->dehydrateState($value); + } + + return filter_var($value, FILTER_VALIDATE_BOOLEAN); + } + + /** + * Write only this column's attribute, running the column's state callbacks around the save. + */ + protected function persist(object $column, Model $record, string $name, mixed $value): void + { + if (method_exists($column, 'getBeforeStateUpdated') && ($before = $column->getBeforeStateUpdated())) { + $before($record, $name, $value); + } + + $record->setAttribute($name, $value); + $record->save(); + + if (method_exists($column, 'getAfterStateUpdated') && ($after = $column->getAfterStateUpdated())) { + $after($record, $name, $value); + } + } +} diff --git a/src/PanelServiceProvider.php b/src/PanelServiceProvider.php index b81109d..3907119 100644 --- a/src/PanelServiceProvider.php +++ b/src/PanelServiceProvider.php @@ -44,8 +44,6 @@ use Laravilt\Panel\Tenancy\MultiDatabaseManager; use Laravilt\Support\Frontend; use Laravilt\Tables\ApiResource; -use Laravilt\Tables\Columns\ToggleColumn; -use Laravilt\Tables\Table; class PanelServiceProvider extends ServiceProvider { @@ -721,57 +719,10 @@ protected function registerSimpleResourceRoutes(string $resourceClass, string $s */ protected function registerColumnUpdateRoute(string $resourceClass, string $slug, string $modelClass, Panel $panel): void { - Route::patch($slug.'/{id}/column', function () use ($resourceClass, $modelClass) { - // Use named route parameter to handle subdomain routes where {tenant} is also a parameter - $id = request()->route('id'); - $record = $modelClass::findOrFail($id); - $column = request()->input('column'); - $value = request()->input('value'); - - // Validate input - if (empty($column)) { - return back()->withErrors(['column' => 'Column name is required.']); - } - - // Get the table configuration to find the column and its callbacks - $table = new Table; - $table = $resourceClass::table($table); - $columns = $table->getColumns(); - - // Find the column configuration - $columnConfig = null; - foreach ($columns as $col) { - if ($col->getName() === $column) { - $columnConfig = $col; - break; - } - } - - // Check if column exists and is editable - if (! $columnConfig) { - return back()->withErrors([$column => 'Column not found.']); - } - - // Execute beforeStateUpdated callback if exists - if ($columnConfig instanceof ToggleColumn) { - $beforeCallback = $columnConfig->getBeforeStateUpdated(); - if ($beforeCallback) { - $beforeCallback($record, $column, $value); - } - } - - // Update the record - $record->update([$column => $value]); - - // Execute afterStateUpdated callback if exists - if ($columnConfig instanceof ToggleColumn) { - $afterCallback = $columnConfig->getAfterStateUpdated(); - if ($afterCallback) { - $afterCallback($record, $column, $value); - } - } - - return back(); + // Only editable, non-disabled columns of the resource table can be written, on a tenant-scoped record the + // user may update, with the column's validation rules (see ColumnStateController) + Route::patch($slug.'/{id}/column', function () use ($resourceClass) { + return app(Http\Controllers\ColumnStateController::class)->updateResourceColumn(request(), $resourceClass); })->name('resources.'.$slug.'.column.update'); } @@ -1082,50 +1033,10 @@ protected function registerRelationManagerRoutes(string $resourceClass, string $ })->name('resources.'.$slug.'.relations.bulk-delete'); // Route: PATCH /{slug}/{id}/relations/{relationship}/{relationId}/column - Update single column (for toggle columns) - Route::patch($slug.'/{id}/relations/{relationship}/{relationId}/column', function () use ($resourceClass, $modelClass) { - // Use named route parameters to handle subdomain routes where {tenant} is also a parameter - $id = request()->route('id'); - $relationship = request()->route('relationship'); - $relationId = request()->route('relationId'); - $record = $modelClass::findOrFail($id); - - // Find the relation manager class - $relationManagers = $resourceClass::getRelations(); - $relationManagerClass = null; - - foreach ($relationManagers as $rmClass) { - if ($rmClass::getRelationship() === $relationship) { - $relationManagerClass = $rmClass; - break; - } - } - - if (! $relationManagerClass) { - if (request()->wantsJson()) { - return response()->json(['error' => 'Relation manager not found'], 404); - } - - return back()->withErrors(['error' => 'Relation manager not found']); - } - - // Find and update the related record's column - $relatedRecord = $record->{$relationship}()->findOrFail($relationId); - $column = request()->input('column'); - $value = request()->input('value'); - - if ($column) { - $relatedRecord->update([$column => $value]); - } - - // Return JSON for AJAX requests, redirect for Inertia - if (request()->wantsJson() && ! request()->header('X-Inertia')) { - return response()->json([ - 'success' => true, - 'data' => $relatedRecord, - ]); - } - - return back(); + // Only editable, non-disabled columns of the relation manager's table can be written, on a related record of + // the tenant-scoped owner, when the user may edit it (see ColumnStateController) + Route::patch($slug.'/{id}/relations/{relationship}/{relationId}/column', function () use ($resourceClass) { + return app(Http\Controllers\ColumnStateController::class)->updateRelationColumn(request(), $resourceClass); })->name('resources.'.$slug.'.relations.column'); } diff --git a/tests/Feature/ColumnUpdateRouteTest.php b/tests/Feature/ColumnUpdateRouteTest.php new file mode 100644 index 0000000..bba5b8b --- /dev/null +++ b/tests/Feature/ColumnUpdateRouteTest.php @@ -0,0 +1,354 @@ +hasMany(ColumnTestComment::class, 'post_id'); + } +} + +class ColumnTestComment extends Model +{ + protected $table = 'comments'; + + protected $guarded = []; +} + +class ColumnTestCommentsRelationManager extends RelationManager +{ + protected static string $relationship = 'comments'; + + public static bool $readOnly = false; + + public function isReadOnly(): bool + { + return static::$readOnly; + } + + public function table(Table $table): Table + { + return $table->columns([ + TextColumn::make('body'), + ToggleColumn::make('is_approved'), + ]); + } +} + +class ColumnTestPostResource extends Resource +{ + protected static string $model = ColumnTestPost::class; + + protected static ?string $slug = 'posts'; + + /** Simulates the resource's update authorization (permission / policy) */ + public static bool $allowUpdate = true; + + /** Simulates tenant scoping: only records of this team are visible */ + public static ?int $teamId = null; + + /** @var array */ + public static array $callbacks = []; + + public static function canUpdate(?Model $record = null): bool + { + return static::$allowUpdate; + } + + public static function getEloquentQuery(): Builder + { + $query = parent::getEloquentQuery(); + + return static::$teamId === null ? $query : $query->where('team_id', static::$teamId); + } + + public static function getRelations(): array + { + return [ColumnTestCommentsRelationManager::class]; + } + + public static function table(Table $table): Table + { + return $table->columns([ + TextColumn::make('title'), + ToggleColumn::make('is_published') + ->beforeStateUpdated(function ($record, $column, $value) { + static::$callbacks[] = ['before', $value]; + }) + ->afterStateUpdated(function ($record, $column, $value) { + static::$callbacks[] = ['after', $value]; + }), + ToggleColumn::make('is_locked')->disabled(), + SelectColumn::make('status')->options(['draft' => 'Draft', 'live' => 'Live']), + ]); + } +} + +beforeEach(function () { + Schema::create('users', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('email'); + $table->string('password'); + $table->timestamps(); + }); + + Schema::create('posts', function (Blueprint $table) { + $table->id(); + $table->string('title'); + $table->unsignedBigInteger('team_id')->nullable(); + $table->boolean('is_published')->default(false); + $table->boolean('is_locked')->default(false); + $table->string('status')->default('draft'); + $table->boolean('is_admin')->default(false); + $table->timestamps(); + }); + + Schema::create('comments', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('post_id'); + $table->string('body'); + $table->boolean('is_approved')->default(false); + $table->boolean('is_admin')->default(false); + $table->timestamps(); + }); + + ColumnTestPostResource::$allowUpdate = true; + ColumnTestPostResource::$teamId = null; + ColumnTestPostResource::$callbacks = []; + ColumnTestCommentsRelationManager::$readOnly = false; + + // Register the real panel route closures (the same code the panel registers for every resource) + $provider = app()->getProvider(PanelServiceProvider::class); + $panel = Panel::make('admin'); + + Route::middleware('web')->prefix('admin')->group(function () use ($provider, $panel) { + (function () use ($panel) { + $this->registerColumnUpdateRoute(ColumnTestPostResource::class, 'posts', ColumnTestPost::class, $panel); + $this->registerRelationManagerRoutes(ColumnTestPostResource::class, 'posts', ColumnTestPost::class, $panel); + })->call($provider); + }); + + $this->actingAs(ColumnTestUser::create(['name' => 'User', 'email' => 'user@example.com', 'password' => 'secret'])); + + $this->post = ColumnTestPost::create(['title' => 'Hello', 'team_id' => 1]); + $this->comment = $this->post->comments()->create(['body' => 'Nice']); +}); + +describe('resource column route', function () { + it('lets an authorized user toggle an editable column', function () { + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'is_published', 'value' => 1]) + ->assertOk() + ->assertJson(['success' => true, 'column' => 'is_published']); + + expect($this->post->fresh()->is_published)->toBe(1) + ->and(ColumnTestPostResource::$callbacks)->toBe([['before', true], ['after', true]]); + }); + + it('redirects back for Inertia requests', function () { + $this->from('/admin/posts') + ->patch('/admin/posts/'.$this->post->id.'/column', ['column' => 'is_published', 'value' => 1], ['X-Inertia' => 'true']) + ->assertRedirect('/admin/posts'); + + expect((bool) $this->post->fresh()->is_published)->toBeTrue(); + }); + + it('returns 403 when the user may not update the record', function () { + ColumnTestPostResource::$allowUpdate = false; + + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'is_published', 'value' => 1]) + ->assertForbidden(); + + expect((bool) $this->post->fresh()->is_published)->toBeFalse(); + }); + + it('rejects a non-editable text column', function () { + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'title', 'value' => 'Hacked']) + ->assertForbidden(); + + expect($this->post->fresh()->title)->toBe('Hello'); + }); + + it('rejects a column that is not in the table', function () { + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'is_admin', 'value' => 1]) + ->assertNotFound(); + + expect((bool) $this->post->fresh()->is_admin)->toBeFalse(); + }); + + it('rejects a disabled column', function () { + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'is_locked', 'value' => 1]) + ->assertForbidden(); + }); + + it('rejects a missing column name', function () { + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['value' => 1]) + ->assertStatus(422); + }); + + it('rejects a non-boolean value on a toggle', function () { + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'is_published', 'value' => 'yes please']) + ->assertStatus(422) + ->assertJsonValidationErrors('is_published'); + + expect((bool) $this->post->fresh()->is_published)->toBeFalse(); + }); + + it('only accepts the allowed options on a select column', function () { + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'status', 'value' => 'deleted']) + ->assertStatus(422); + + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'status', 'value' => 'live']) + ->assertOk(); + + expect($this->post->fresh()->status)->toBe('live'); + }); + + it('only writes the column attribute', function () { + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'is_published', 'value' => 1, 'is_admin' => 1, 'title' => 'x']) + ->assertOk(); + + $post = $this->post->fresh(); + expect((bool) $post->is_admin)->toBeFalse()->and($post->title)->toBe('Hello'); + }); + + it('loads the record through the resource query (tenant scoping)', function () { + ColumnTestPostResource::$teamId = 2; + + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'is_published', 'value' => 1]) + ->assertNotFound(); + + expect((bool) $this->post->fresh()->is_published)->toBeFalse(); + }); +}); + +describe('relation manager column route', function () { + function relationColumnUrl($test): string + { + return '/admin/posts/'.$test->post->id.'/relations/comments/'.$test->comment->id.'/column'; + } + + it('lets an authorized user toggle an editable relation column', function () { + $this->patchJson(relationColumnUrl($this), ['column' => 'is_approved', 'value' => true]) + ->assertOk() + ->assertJson(['success' => true]); + + expect((bool) $this->comment->fresh()->is_approved)->toBeTrue(); + }); + + it('rejects an arbitrary attribute', function () { + $this->patchJson(relationColumnUrl($this), ['column' => 'is_admin', 'value' => 1]) + ->assertNotFound(); + + $this->patchJson(relationColumnUrl($this), ['column' => 'post_id', 'value' => 999]) + ->assertNotFound(); + + $this->patchJson(relationColumnUrl($this), ['column' => 'body', 'value' => 'Hacked']) + ->assertForbidden(); + + $comment = $this->comment->fresh(); + expect((bool) $comment->is_admin)->toBeFalse() + ->and($comment->post_id)->toBe($this->post->id) + ->and($comment->body)->toBe('Nice'); + }); + + it('rejects a non-boolean value', function () { + $this->patchJson(relationColumnUrl($this), ['column' => 'is_approved', 'value' => 'abc']) + ->assertStatus(422); + }); + + it('returns 403 when the owner record may not be updated', function () { + ColumnTestPostResource::$allowUpdate = false; + + $this->patchJson(relationColumnUrl($this), ['column' => 'is_approved', 'value' => 1]) + ->assertForbidden(); + }); + + it('returns 403 when the relation manager is read-only', function () { + ColumnTestCommentsRelationManager::$readOnly = true; + + $this->patchJson(relationColumnUrl($this), ['column' => 'is_approved', 'value' => 1]) + ->assertForbidden(); + }); + + it('rejects a related record that does not belong to the owner', function () { + $other = ColumnTestPost::create(['title' => 'Other']); + $foreign = $other->comments()->create(['body' => 'Foreign']); + + $this->patchJson('/admin/posts/'.$this->post->id.'/relations/comments/'.$foreign->id.'/column', ['column' => 'is_approved', 'value' => 1]) + ->assertNotFound(); + + expect((bool) $foreign->fresh()->is_approved)->toBeFalse(); + }); +}); + +describe('legacy laravilt/tables without the EditableColumn contract', function () { + beforeEach(function () { + // Simulates tables 1.0.x, where EditableColumn / getStateValidationRules() do not exist + app()->bind(ColumnStateController::class, fn () => new class extends ColumnStateController + { + protected function supportsEditableColumns(): bool + { + return false; + } + }); + }); + + it('still lets a toggle column be updated with a boolean', function () { + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'is_published', 'value' => 1]) + ->assertOk(); + + expect((bool) $this->post->fresh()->is_published)->toBeTrue() + ->and(ColumnTestPostResource::$callbacks)->toBe([['before', true], ['after', true]]); + }); + + it('rejects a non-boolean toggle value', function () { + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'is_published', 'value' => 'abc']) + ->assertStatus(422); + }); + + it('only allows toggle columns', function () { + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'status', 'value' => 'live']) + ->assertForbidden(); + + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'title', 'value' => 'x']) + ->assertForbidden(); + + expect($this->post->fresh()->status)->toBe('draft'); + }); + + it('still requires update authorization', function () { + ColumnTestPostResource::$allowUpdate = false; + + $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'is_published', 'value' => 1]) + ->assertForbidden(); + }); +}); From 95de0fadd992e7ee973dc09fb78765a2c7d4b9f7 Mon Sep 17 00:00:00 2001 From: Fady Mondy Date: Tue, 15 Sep 2026 10:25:06 +0300 Subject: [PATCH 2/2] Skip the select column test when tables lacks EditableColumn CI installs laravilt/tables 1.0.x, where the fallback only allows ToggleColumn, so select option validation cannot be exercised there. Co-Authored-By: Claude Opus 5 (1M context) --- tests/Feature/ColumnUpdateRouteTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Feature/ColumnUpdateRouteTest.php b/tests/Feature/ColumnUpdateRouteTest.php index bba5b8b..d393a30 100644 --- a/tests/Feature/ColumnUpdateRouteTest.php +++ b/tests/Feature/ColumnUpdateRouteTest.php @@ -12,6 +12,7 @@ use Laravilt\Panel\PanelServiceProvider; use Laravilt\Panel\Resources\RelationManagers\RelationManager; use Laravilt\Panel\Resources\Resource; +use Laravilt\Tables\Columns\Contracts\EditableColumn; use Laravilt\Tables\Columns\SelectColumn; use Laravilt\Tables\Columns\TextColumn; use Laravilt\Tables\Columns\ToggleColumn; @@ -230,7 +231,7 @@ public static function table(Table $table): Table ->assertOk(); expect($this->post->fresh()->status)->toBe('live'); - }); + })->skip(fn () => ! interface_exists(EditableColumn::class), 'requires laravilt/tables with EditableColumn'); it('only writes the column attribute', function () { $this->patchJson('/admin/posts/'.$this->post->id.'/column', ['column' => 'is_published', 'value' => 1, 'is_admin' => 1, 'title' => 'x'])