Skip to content
Bladewell
Guides

Editable table cells in Laravel with Livewire

Rename a record inside a Blade table: Enter saves, Escape cancels, errors stay beside the input, and a name changed by someone else is never overwritten.

php artisan bladewell:add table text-input button

Uses Table, Text input and Button. New to Bladewell? Install it first.

One small component per cell

There's no editable-cell widget to install: a table cell can hold anything, so it holds a Livewire component with a text input and two buttons. Each cell is its own component, with its own draft and its own errors, so an invalid name in one row never shows under another, and several rows can be open at once.

This guide renames a Product that belongs to a User (a user() relation and a name column). It uses Livewire 3 or 4 class components and no Alpine.

Who may rename it

Start with a policy, or keep yours if you have one. Named this way, Laravel finds it on its own.

app/Policies/ProductPolicy.php
namespace App\Policies;

use App\Models\Product;
use App\Models\User;

class ProductPolicy
{
    public function update(User $user, Product $product): bool
    {
        return $product->user()->is($user);
    }
}

The table

The list is a Livewire component too, so the table's page links go through Livewire and each new page mounts its own cells.

app/Livewire/Products.php
namespace App\Livewire;

use App\Models\Product;
use Livewire\Component;
use Livewire\WithPagination;

class Products extends Component
{
    use WithPagination;

    public function render()
    {
        return view('livewire.products', [
            'products' => Product::query()
                ->where('user_id', auth()->id())
                ->orderBy('id')
                ->paginate(20),
        ]);
    }
}

Each row gets a wire:key, and each cell a :key, from the product's id, not its position, so an open editor stays with its product when rows move. The component renders a div inside the td, which keeps the table's markup valid; never wrap a tr in a form.

resources/views/livewire/products.blade.php
<div>
    <x-widget.table
        id="products"
        caption="Products"
        :rows="$products"
        pagination="numbers"
        :columns="[
            ['label' => 'ID'],
            ['label' => 'Product name'],
        ]"
    >
        @foreach ($products as $product)
            <x-widget.table.row wire:key="product-row-{{ $product->id }}">
                <td>{{ $product->id }}</td>
                <td>
                    @can('update', $product)
                        <livewire:product-name-cell :product-id="$product->id" :key="'product-name-'.$product->id" />
                    @else
                        {{ $product->name }}
                    @endcan
                </td>
            </x-widget.table.row>
        @endforeach
    </x-widget.table>
</div>

Put <livewire:products /> on a page behind the auth middleware.

The cell

Only name can change. Never take a column name from the browser and pass it to update().

app/Livewire/ProductNameCell.php
namespace App\Livewire;

use App\Models\Product;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Locked;
use Livewire\Component;

class ProductNameCell extends Component
{
    // Locked: the browser can't change which product this is, what it was called, or whether it's being edited.
    #[Locked]
    public int $productId;

    #[Locked]
    public string $name = '';

    #[Locked]
    public string $originalName = '';

    #[Locked]
    public bool $editing = false;

    public string $draft = '';

    public string $notice = '';

    public function mount(int $productId): void
    {
        $this->productId = $productId;
        $this->name = $this->product()->name;
    }

    public function edit(): void
    {
        $product = $this->product();
        Gate::authorize('update', $product);

        $this->resetValidation();
        $this->notice = '';
        $this->name = $this->originalName = $this->draft = $product->name;
        $this->editing = true;
    }

    public function cancel(): void
    {
        $this->resetValidation();
        $this->draft = '';
        $this->editing = false;
        $this->notice = 'Changes discarded.';
    }

    public function save(): void
    {
        abort_unless($this->editing, 409);
        Gate::authorize('update', $this->product());

        $this->draft = trim($this->draft);
        $validated = $this->validate(
            ['draft' => ['required', 'string', 'max:120']],
            attributes: ['draft' => 'product name'],
        );

        DB::transaction(function () use ($validated): void {
            $product = $this->product(lock: true);
            Gate::authorize('update', $product);

            // Someone else renamed it since Edit was pressed: say so rather than overwrite their name.
            if ($product->name !== $this->originalName) {
                throw ValidationException::withMessages([
                    'draft' => 'This name changed while you were editing. Copy your text, cancel, and edit again to see the new name.',
                ]);
            }

            $product->name = $validated['draft'];
            $product->save();
        });

        $this->name = $validated['draft'];
        $this->editing = false;
        $this->notice = 'Product name saved.';
    }

    // Only ever one of the signed-in person's own products.
    private function product(bool $lock = false): Product
    {
        return Product::query()
            ->where('user_id', auth()->id())
            ->when($lock, fn ($query) => $query->lockForUpdate())
            ->findOrFail($this->productId);
    }

    public function render()
    {
        return view('livewire.product-name-cell');
    }
}

Locked properties stop the browser from changing the product's id or the name it started from, but they don't replace authorisation: every query is scoped to the signed-in person's products, and the policy is asked again on save.

The save compares the name in the database with the one loaded when Edit was pressed, under a row lock, and refuses if they differ. That catches a rename by someone else in between. To catch every kind of change to the row, add a version column that every write increments, and compare that instead.

resources/views/livewire/product-name-cell.blade.php
<div data-inline-editor data-editing="{{ $editing ? 'true' : 'false' }}">
    @if ($editing)
        <form wire:submit="save" class="grid gap-2">
            {{-- An id per product: the default, from wire:model, would be "draft" in every row. --}}
            <x-widget.text-input
                id="product-name-{{ $productId }}"
                label="Product name"
                wire:model="draft"
                maxlength="120"
                required
                wire:keydown.escape.prevent.stop="cancel"
            />

            <div class="flex flex-wrap gap-2">
                <x-widget.button type="submit" size="sm">Save</x-widget.button>
                <x-widget.button variant="neutral" size="sm" wire:click="cancel">Cancel</x-widget.button>
            </div>
        </form>
    @else
        <div class="flex items-center gap-3">
            <span>{{ $name }}</span>
            <x-widget.button variant="link" size="sm" wire:click="edit" aria-label="Edit {{ $name }}">Edit</x-widget.button>
        </div>
    @endif

    <p role="status" class="text-muted text-sm">{{ $notice }}</p>
    <p wire:offline role="status" class="text-sm">You're offline. Reconnect before saving.</p>
</div>
  • Enter saves, as in any one-field form. Escape cancels, and so does the Cancel button, for touch. Leaving the field saves nothing.
  • While a save runs, Livewire makes the input read-only and the Save button shows it's busy, so it can't be sent twice.
  • A failed save, from validation or from a rename in between, keeps the editor open with the error under the input. The editor closes only once the server says the name was saved.
  • The Edit button's name includes the product's, "Edit Desk lamp", so a screen reader list of buttons isn't a column of identical "Edit"s.
  • Keep the input outside wire:ignore, so new values and errors reach it.

Keep the keyboard where it was

Pressing Edit replaces the button with a form, and Save replaces the form with the button, so whatever had focus is gone and the keyboard lands at the top of the page. This script moves focus to the input on Edit, and back to the Edit button after Save or Cancel. It never writes a value or calls an action.

resources/js/inline-edit-focus.js
const modes = new WeakMap();

// When a cell switches between showing its name and editing it, the element that had focus is gone: move focus to
// the input on Edit, and back to the Edit button after Save or Cancel.
function follow() {
    document.querySelectorAll('[data-inline-editor]').forEach((editor) => {
        const mode = editor.dataset.editing;
        const previous = modes.get(editor);
        modes.set(editor, mode);

        // Not on the first look at a cell: the page loading shouldn't steal focus.
        if (previous === undefined || previous === mode) {
            return;
        }

        requestAnimationFrame(() => {
            if (editor.isConnected) {
                editor.querySelector(mode === 'true' ? 'input:not([type="hidden"])' : 'button')?.focus();
            }
        });
    });
}

follow();
new MutationObserver(follow).observe(document.body, { subtree: true, childList: true, attributeFilter: ['data-editing'] });

Import it once from resources/js/app.js with import './inline-edit-focus';, and rebuild.

Test it

Livewire's test helper calls the actions as the browser would, without the browser's checks, which is the point: the server has to refuse an empty name, a 121-character one and someone else's product on its own. These assume a Product factory that creates its owner.

tests/Feature/ProductNameCellTest.php
use App\Livewire\ProductNameCell;
use App\Models\Product;
use App\Models\User;
use Livewire\Livewire;

it('renames a product', function () {
    $product = Product::factory()->create(['name' => 'Desk lamp']);

    Livewire::actingAs($product->user)
        ->test(ProductNameCell::class, ['productId' => $product->id])
        ->call('edit')
        ->set('draft', '  Floor lamp  ')
        ->call('save')
        ->assertHasNoErrors()
        ->assertSet('editing', false);

    expect($product->fresh()->name)->toBe('Floor lamp');
});

it('keeps the editor open on an empty or overlong name', function (string $name) {
    $product = Product::factory()->create(['name' => 'Desk lamp']);

    Livewire::actingAs($product->user)
        ->test(ProductNameCell::class, ['productId' => $product->id])
        ->call('edit')
        ->set('draft', $name)
        ->call('save')
        ->assertHasErrors('draft')
        ->assertSet('editing', true);

    expect($product->fresh()->name)->toBe('Desk lamp');
})->with(['', str_repeat('a', 121)]);

it('does not overwrite a name changed by someone else', function () {
    $product = Product::factory()->create(['name' => 'Desk lamp']);
    $cell = Livewire::actingAs($product->user)
        ->test(ProductNameCell::class, ['productId' => $product->id])
        ->call('edit');

    $product->update(['name' => 'Table lamp']);
    $cell->set('draft', 'Floor lamp')->call('save')->assertHasErrors('draft');

    expect($product->fresh()->name)->toBe('Table lamp');
});

it('does not open another person\'s product', function () {
    $product = Product::factory()->create();

    Livewire::actingAs(User::factory()->create())
        ->test(ProductNameCell::class, ['productId' => $product->id])
        ->assertStatus(404);
});

Then check by hand what a test can't: Tab to Edit, type, press Enter, and focus is back on Edit; open the same product in two windows and save both, and the second gets the conflict; go offline and save, and the draft stays where it was.

What it doesn't do

  • A lost connection, an expired session or a server error gets Livewire's usual handling; there's no offline queue. Don't retry a save on your own: the answer may have been lost after the name was saved. Load the current name first.
  • Moving to another page drops any open drafts.
  • The cell updates its own name only. It doesn't re-sort the table or tell other people's browsers. For that, dispatch an event on save and refresh the list, taking care not to close someone's open editor.

Sorting, filters and paginating the same table are in the data table guide.