Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[LiveComponent] Include prop name in the url modifier function #2650

Open
jannes-io opened this issue Mar 23, 2025 · 7 comments · May be fixed by #2652
Open

[LiveComponent] Include prop name in the url modifier function #2650

jannes-io opened this issue Mar 23, 2025 · 7 comments · May be fixed by #2652

Comments

@jannes-io
Copy link
Contributor

jannes-io commented Mar 23, 2025

Problem

The current (lack of) DX is best visible when defining abstract re-usable live components.

Let's have a minimal example, using something like a simple list with pagination:

// parent
abstract class AbstractList
{
    use DefaultActionTrait;

    #[LiveProp(writable: true, url: true)]
    public int $page = 1;

    abstract public function getItems(): array;
}

// child 1
#[AsLiveComponent(name: 'FirstCoolList')]
class FirstCoolList extends AbstractList
{
    public function getItems(): array
    {
        // get items from database using $this->page to set offset/limit yadda yadda, not the point of this issue.
    }
}

// child 2
#[AsLiveComponent(name: 'SecondCoolList')]
class SecondCoolList extends AbstractList
{
    public function getItems(): array
    {
        // get items from database using $this->page to set offset/limit yadda yadda, not the point of this issue.
    }
}

Now when we render both components, we can see that the page param will be in conflict.. yikes... not good..

{{ component('FirstCoolList') }}
{{ component('SecondCoolList') }}

So.. since the param is declared in the parent, we can't just change the url anymore, we have to use a modifier. We can follow the LiveComponent documentation and it'll allow us to change the URL parameters based a parameter. Ok cool let's add that.

abstract class AbstractList
{
    // ..

    #[LiveProp(writable: true, url: true, modifier: 'modifyPageQuery')]
    public int $page = 1;

    #[LiveProp]
    public string $pageParamAlias = 'page';

    // ..

    public function modifyPageQuery(LiveProp $prop): LiveProp
    {
        return $prop->withUrl(new UrlMapping($this->pageParamAlias));
    }
}

And we use it:

{{ component('FirstCoolList', { pageParamAlias: 'firstListPage' }) }}
{{ component('SecondCoolList', { pageParamAlias: 'secondListPage' }) }}

Now we modify our abstract class, because we don't just need to know the page, we also want to capture some search query the user has entered.

abstract class AbstractList
{
    // ..

    #[LiveProp(writable: true, url: true, modifier: 'modifySearchQuery')]
    public string $query = 1;

    #[LiveProp]
    public string $searchParamAlias = 'q';

    #[LiveProp(writable: true, url: true, modifier: 'modifyPageQuery')]
    public int $page = 1;

    #[LiveProp]
    public string $pageParamAlias = 'page';

    // ..

    public function modifySearchQuery(LiveProp $prop): LiveProp
    {
        return $prop->withUrl(new UrlMapping($this->searchParamAlias));
    }

    public function modifyPageQuery(LiveProp $prop): LiveProp
    {
        return $prop->withUrl(new UrlMapping($this->pageParamAlias));
    }
}

To make sure our lists don't collide, we again need to modify both usages as well:

{{ component('FirstCoolList', { pageParamAlias: 'firstListPage', searchParamAlias: 'firstListSearch' }) }}
{{ component('SecondCoolList', { pageParamAlias: 'secondListPage', searchParamAlias: 'secondListSearch' }) }}

Rinse and repeat for 4 or 5 other params, and you can see how the abstract class is starting to get out of hand, and in terms of maintainability, if the usage of the components is spread out over multiple Symfony bundles, hundreds of templates,... this becomes absolutely impossible to maintain.

Proposal

Pass the name of the property down into the modify function, either in the LiveProp object, or as a second parameter. This way we can configure 1 prefix on the component that can be applied to all props.

abstract class AbstractList
{
    // ..

    #[LiveProp(writable: true, url: true, modifier: 'addPrefix')]
    public int $page = 1;

    #[LiveProp]
    public string $queryPrefix= '';

    // ..

   // PROPOSAL 1:
    public function addPrefix(LiveProp $prop): LiveProp
    {
        $param = $this->queryPrefix . $prop->getName(); // "page"
        return $prop->withUrl(new UrlMapping($param));
    }

    // PROPOSAL 2:
    public function addPrefix(LiveProp $prop, string $propName): LiveProp
    {
        $param = $this->queryPrefix . $propName; // "page"
        return $prop->withUrl(new UrlMapping($param));
    }
}

Now we no longer need to modify usages of the component whenever we add new properties that are controllable by the URL.
Both proposals would be completely backwards compatible, since we're just adding another param or new field on LiveProp, we're not changing the behavior of the modifier.

It's unlikely that multiple lists of the same component are shown on the same page meanwhile it's very possible that multiple lists of different components are shown on 1 page so in my case I would actually set the prefix as a protected field on the implementation of the list, but I didn't want to use this use-case in the example since it's very application dependent.

What are your thoughts?

@Kocal
Copy link
Member

Kocal commented Mar 27, 2025

Hi, it makes totally sense and I like your proposal, but I'm not super confident about modifying/adding things in LiveComponent. WDYT @smnandre?

@smnandre
Copy link
Member

Hi @jannes-io, and thanks a lot for the detailed write-up and the thoughtful proposal.

I completely get where you're coming from. That kind of growing complexity in abstract Live Components can become a real DX issue — especially when you're trying to build reusability and keep your templates clean. Your prefix idea makes sense in isolation, and I like how you kept it backward compatible.

That said, I’m not sure this is something we should add at the framework level just yet. A few reasons:

  • I feel like the core issue isn’t so much a missing feature, but more a signal that the architecture might need to shift. The inheritance pattern here is pushing a lot of implicit behavior down to the template layer, which quickly becomes brittle — especially when parameters start multiplying.
  • When a component’s usage requires this many custom aliases just to avoid internal conflicts, I’d argue the abstraction is leaking too much. Ideally, the template shouldn’t have to know about any of that.
  • More broadly: every new feature has a cost — not just in code, but in documentation, maintenance, and constraints on future evolutions. We try to keep the surface minimal and focused on widely shared needs.

In your case, I wonder if those prefixes shouldn't live inside each concrete component instead. If the lists are designed to coexist on the same page, it might be a responsibility of the child classes to disambiguate — not of the parent, and not of the user in the template.

Finally, I'd love to better understand the real-world use case: do you have other examples (outside abstract paginated lists) where this pattern causes trouble? I’m open to discussing alternatives — maybe using mount() or postMount() hooks, or even reconsidering how/when props end up in the URL.

Let’s keep the conversation going — thanks again for opening this!


(Disclosure: I used Copilot to help find the right words and tone, as I wanted to make sure my message truly reflected what I had in mind).

@jannes-io
Copy link
Contributor Author

jannes-io commented Mar 30, 2025

Hello @smnandre,

Thanks for your response.

In your case, I wonder if those prefixes shouldn't live inside each concrete component instead. If the lists are designed to coexist on the same page, it might be a responsibility of the child classes to disambiguate — not of the parent, and not of the user in the template.

Yes exactly, that is what I meant with my little sidenote:

in my case I would actually set the prefix as a protected field on the implementation of the list

So the example I used is an actual use case in our communications platform:
https://github.com/forumify/forumify-platform/blob/master/src/Core/Component/List/AbstractList.php

Our immediate use case is that we like to control the lastPageFirst prop from within email communication and hotlinking.
For example take our messenger page, it includes 2 of these lists, one list that shows all the conversations, and one list that shows all the messages within a single conversation. Now in some locations we want to link to the last page, for example when the user receives a "new message" email. In others, we want to go to the first page, for example when browsing a previously unseen message thread. There have been some UX complaints regarding this specifically.

Trigger for this issue: forumify/forumify-platform#73

If we make the prop url: true and we have no way of prefixing the query parameters, adding the lastPageFirst=1 query param now makes both lists move to the last page, not good 😕

For completion here is the message list and message thread list implementations:
MessageList - template
MessageThreadList - template

An alternative example where we really want to enable URL params but can't at the moment because of possible collisions are our tables.

So to summarize, for our actual use-case we would be using a property defined on the list/table as the prefix, and we wouldn't be passing it through the template, but even for that, there's no way to accomplish that at the moment without defining a function for every prop (as far as I can tell?).
Regardless of the use-case of prefixing, imo the name of the property is something that should be known in LiveProp or in the modify function, it seems more like an oversight that it is missing at the moment.

Might there be any other possible avenues we could go down to accomplish the same result without needing a lot of change to our public API? These lists and tables are already in use and already have hundreds of implementations not just by us, but also by plugin developers and custom applications.

@smnandre
Copy link
Member

You don't have thousand of these abstract, right ?

So maybe something like this would be just enough for what you need ?

abstract class AbstractList
{
    #[LiveProp]
    public string $prefix;

   abstract protected function getFieldAlias(string $fieldname): string;

    #[LiveProp(writable: true, url: true, modifier: 'modifySearchQuery')]
    public string $search = 1;

    #[LiveProp(writable: true, url: true, modifier: 'modifyPageQuery')]
    public int $page = 1;

    public function modifySearchQuery(LiveProp $prop): LiveProp
    {
        return $prop->withUrl(new UrlMapping($this->prefix.'search'));
    }

    public function modifyPageQuery(LiveProp $prop): LiveProp
    {
        return $prop->withUrl(new UrlMapping($this->prefix.'page'));
    }
}

@jannes-io
Copy link
Contributor Author

Yes, that is roughly the temporary solution I was going to implement, but wanted to see if we could improve the modifier to know about the prop name, then we'd be able to re-use the same modifier for multiple properties, and it'd keep the abstractions much slimmer and easier to maintain.

If someone wanted to overwrite this function, it'd also be a matter of modifying only 1 function instead of the function for every single prop. Which has the side-effect of not automatically working for new props that we might add in the future.

Another "shower thought" I had was to integrate pre/suffixing much closer into LiveComponent, maybe even have a parameter on AsLiveComponent that could control the url prefix of all props by default? It would be a less generic solution to what I'm suggesting, since including the name on the modifier or on LiveProp would serve much more than just prefixing. Currently if you have 2 components with the same url params, funky things start to happen, the url is glitching back and forth between the values of the different components, refreshing doesn't produce the same state as it had pre-refresh,.... So maybe it's a more deeply rooted problem of url params perhaps being an after thought rather than a design choice (just my speculation)?

@smnandre
Copy link
Member

maybe even have a parameter on AsLiveComponent

I've been thinking on this one too. And it could be promising, but we need to be sure it's a common need and not only specific to some projects (in the functional needs i mean).

then we'd be able to re-use the same modifier for multiple properties

hahaha well ... maybe you still should reverse your logic. When you have an blog, a list of categories, a list of posts, a list of authors... do you often see parameters called something else than "page" or "p" ? I mean they are not blog_post_page, they are "page". Because the HTTP resource you call has a "main/primary" content.

So i'm still a bit skeptical to be honest about the whole things right now.

see if we could improve the modifier to know about the prop name

I suppose we could, but not sure it would help that many people.

Now i'll let the other ones and not try to "influence" or "contaminate" everyone!

:)

@jannes-io
Copy link
Contributor Author

jannes-io commented Mar 31, 2025

When you have an blog, a list of categories, a list of posts, a list of authors... do you often see parameters called something else than "page" or "p" ?

Yes, I agree, would it be possible to dynamically enable/disable the url option in LiveProp? For example passing it through the twig template? Because the same list on one page could be the main content, while on another page it could be just an aside.

Sticking with the messenger example, the conversations list is the main content when no conversation is selected, but once a conversation is selected, the main list would be the messages inside of the conversation, but the conversation list is still open.

/messenger?page=3 => page 3 of conversations
/messenger/10?page=3 => page 3 of messages in conversation 10 (but this page also renders the same list from the other page)

On the second link, I probably still want to keep the page the user was on for the conversations too, so it becomes like /messenger/10?page=3&conversations_page=3? 🤔

I don't really know, probably over-complicating things at this point haha.

Let's stick to the original problem though, just being able to control 1 LiveComponent through the url, configurable either through some implementation or the usage in Twig, without affecting all others.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants