Be part of JetBrains PHPverse 2026 on June 9 – a free online event bringing PHP devs worldwide together.

jsellars's avatar

Livewire + Alpine optimisation

I have a component that displays the title of a model, then when you click on the button it switches to a form with one field for the title. This component is intended to be displayed for a collection of models. It does the job but I have some queries.

  1. For all the functions that are called as a result of events, the only way I could manage to isolate the component that needs it, is to have a check for $model_id which means there is still a request for every component. Is there a better way to do this? I have read the docs.

  2. minor issue. I would like the form to be submitted by clicking away from the component (ie. no submit button). The current implementation works with wire:model.lazy="title" and having the updatedTitle() listen for it, however I can't call updatedTitle() in my pest tests. again, I wonder if there is a better way to do this? I'm using filament for the forms trait on the component

// HeadingForm.php

<?php

namespace App\Http\Livewire;

use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Actions\Action;
use Filament\Notifications\Notification;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\ValidationException;
use Livewire\Component;

class HeadingForm extends Component implements HasForms
{
    use InteractsWithForms;

    public Model $model;

    protected $listeners = [
        'deleteAction' => 'deleteModel',
        'recoverAction' => 'recover',
        'focus',
    ];

    public function mount(): void
    {
        $this->form->fill([
            'title' => $this->model->title,
        ]);
    }

    public function recover($model_id): void
    {
        if ($this->model->id === $model_id) {
            $this->mount();
            $this->focus($model_id);
        }
    }

    public function focus($model_id): void
    {
        if ($this->model->id === $model_id) {
            $this->emitSelf('focusInput');
        }
    }

    public function deleteModel($model_id): void
    {
        if ($this->model->id === $model_id) {
            // parent component needs a refreshComponent listener
            $this->emitUp('refreshComponent');
            $this->model->delete();
        }
    }

    // magic method that listens for changes on $this->title
    public function updatedTitle(): void
    {
        $this->model->update($this->form->getState());
    }

    public function render()
    {
        return view('livewire.heading-form');
    }

    protected function getFormSchema(): array
    {
        return [
            TextInput::make('title')
                ->required()
                ->maxLength(255)
                ->label(false)
                ->validationAttribute(
                    str(class_basename($this->model))->headline().' Title'
                ),
        ];
    }

    protected function onValidationError(ValidationException $exception): void
    {
        $error_msg = $exception->getMessage();

        // HACK: find a better way to detect type of exception
        if (! strstr($error_msg, 'required')) {
            Notification::make()
                ->title($exception->getMessage())
                ->danger()
                ->send();

            return;
        }

        // actionable notification if user has left the title field empty
        // ie. if exception contains 'required' message
        Notification::make()
            ->title($error_msg)
            ->body(
                "Did you mean to delete <b>{$this->model->title}</b>? \n \
                This action will remove all related records"
            )
            ->actions([
                Action::make('delete')
                    ->button()
                    ->color('danger')
                    // NOTE: can't emitSelf as Notification is seperated component
                    ->emitTo(
                        'heading-form',
                        'deleteAction',
                        [$this->model->id],
                    )
                    ->close(),
                Action::make('recover')
                    ->emitTo(
                        'heading-form',
                        'recoverAction',
                        [$this->model->id],
                    )
                    ->close(),
            ])
            ->danger()
            ->persistent()
            ->send();
    }
}

// heading-form.blade.php

<x-filament::card x-data="{
    form: false,
    cardWidth: 0,
    limitTitle: function(title) {
        const limit = this.cardWidth / 10;
        return title.length > limit ? title.slice(0, limit).trim() + '...' : title;
    }
}">
    <form x-bind:hidden="form === false" x-show="form" @click.outside="form = false" wire:model.lazy="title"
        x-init="() => { $wire.on('focusInput', () => { form = true }) }" x-trap.inert="form">
        {{ $this->form }}
    </form>
    <div x-show="!form" class="flex items-center justify-between" x-init="cardWidth = $el.parentElement.offsetWidth"
        x-on:resize.window="cardWidth = $el.offsetWidth">
        <h3 x-text="limitTitle('{{ $model->title }}')"></h3>
        <button
            class="relative flex items-center justify-center rounded-full outline-none hover:bg-gray-500/5 text-primary-500 focus:bg-primary-500/10 h-8 w-8"
            @click="form = true" type="button">
            <x-feathericon-edit-2 class="w-4 h-4" />
        </button>
    </div>
</x-filament::card>

// calling the component example

    @forelse($posts as $post)
        <livewire:heading-form :model="$post" :wire:key="$post->id"/>
    @empty
        No Posts
    @endforelse
0 likes
6 replies
jsellars's avatar

Essentially I need to be able to emitTo() an instance of a component rather than the whole component

jsellars's avatar

neither of these work. I get a "Constant expression contains invalid operations" error :/

    public function listeners(): array
    {
        return [
            "deleteAction{$this->model->id}" => 'deleteModel',
            "recoverAction{$this->model->id}" => 'recover',
            'focus',
        ];
    }

    protected $listeners = [
        "deleteAction{$this->model->id}" => 'deleteModel',
        "recoverAction{$this->model->id}" => 'recover',
        'focus',
    ];
willvincent's avatar
Action::make('recover')
                    ->emitTo(
                        'heading-form',
                        'recoverAction',
                        [$this->model->id],
                    )

Looks like you're passing your model id as a parameter, not as part of the event name.

So I think what you want is actually:

 protected $listeners = [
        "deleteAction" => 'deleteModel',
        "recoverAction" => 'recover',
        'focus',
    ];

public function deleteModel($id) { ... }
public function recover($id) { ... }
1 like
jsellars's avatar

@willvincent thanks.

This is what I have currently from my original post. This recent code is an attempt at replicating what was suggested in the github issue

your approach works, however if I have 100 heading-form components on the page, then it is doing 100 requests with each instance checking that they have the id. I'm trying to target a single instance with the event. Hope that makes sense :)

PS: annoyingly, the Action->emitTo() method is slightly different to the livewire one. for example the params are accepted as array instead of variable length arguments. Trying to look into it

jsellars's avatar

solution:

    protected function getListeners(): array
    {
        return [
            'deleteAction'.$this->model->id => 'deleteModel',
            'recoverAction'.$this->model->id => 'recover',
            'focus',
        ];
    }

//...
	
	protected function onValidationError(ValidationException $exception): void
	{
		//...
        Notification::make()
            ->title($error_msg)
            ->body(
                "Did you mean to delete <b>{$this->model->title}</b>? \n \
                This action will remove all related records"
            )
            ->actions([
                Action::make('delete')
                    ->button()
                    ->color('danger')
                    // NOTE: can't emitSelf as Notification is seperated component
                    ->emitTo(
                        'heading-form',
                        "deleteAction{$this->model->id}"
                    )
                    ->close(),
                Action::make('recover')
                    ->emitTo(
                        'heading-form',
                        "recoverAction{$this->model->id}"
                    )->close(),
            ])
            ->danger()
            ->persistent()
            ->send();
	}
}

Please or to participate in this conversation.