slimphp / Slim

Slim is a PHP micro framework that helps you quickly write simple yet powerful web applications and APIs.
http://slimframework.com
MIT License
11.96k stars 1.95k forks source link

[not really an issue] How to override the route controllers? #3349

Open tobiascapin opened 1 week ago

tobiascapin commented 1 week ago

Hello, I'm working with Slim4 and I need to create website "variants" based on the URL hostname. I already completed this task using a middleware:

final class SiteSelectorMiddleware implements MiddlewareInterface{
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $host=$request->getUri()->getHost();
        $request = $request->withAttribute('host', $host);
        //SOME OTHER STUFF
        return $handler->  handle($request);
    }
}

This will pass the hostname as attribute, selects different Twig resources for templates and so on. But now I need also to add some little changes to controllers logics and I don't want to do something like this in my controllers:

class MyAction{
    public function get(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
            if($host=="variantsite") {
                //do something
            }else{
                //do something else
            }
    }
}

I would prefer to use a new Controller class that extends the original one.

class SubClassMyAction extends MyAction{
    public function get(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        //do something
        return parent::get($request,$response);
    }
}

My routes are defined like this:

return function (App $app) {
    $app->get('/mypage', MyAction::class. ':get');
    $app->post('/mypage', MyAction::class. ':post');
}

Is there any way to alter the associated controller runtime in a middleware to change on the fly the previous settings to

return function (App $app) {
    $app->get('/mypage', SubClassMyAction::class. ':home');
    $app->post('/mypage', SubClassMyAction::class. ':home');
}
odan commented 1 week ago

All routes in Slim are bounded only to the URI path. Registering different route handlers based on the hostname is not supported.

I think your initial approach of using SiteSelectorMiddleware is not only more consistent with the PSR-7/PSR-15/Slim architecture but also more maintainable. This approach keeps route definitions clean and focused, while the Action handler handles the domain-specific logic separately.

tobiascapin commented 1 week ago

Thank you for your reply and sorry but probably I did not well explain.

I'm already using the SiteSelectorMiddleware to change some customizable resources, like style and logos based on the hostname. Basically it adds just some twig variables. This is a well working solution.

My question arises when I need to change a bit the controller logic of the route for a particular hostname. I need to change the callable from my standard MyAction::class to a custom SubClassMyAction::class that extends the first one.

How I can change the callable mapping for some routes from a middleware? As far as I know I cannot remap the same url. In the meanwhile, maybe, I found a solution using the routecollector and changing the callable using the route name:

$route=$app->getRouteCollector()->getNamedRoute($routeName);
$route->setCallable(SubClassMyAction::class. ':home');

It seems to be a working solution, so I can change the callable of some routes just for some domain inside the SiteSelector. This solution makes no longer available the cache file mapping. I don't know if this is a good idea or there are some other solutions....

odan commented 1 week ago

My question arises when I need to change a bit the controller logic...

I'm curious, what would be a specific use case for this? Why not simply implement the business logic in a service class and pass the additional values (like the tenant) from the action handler to the service as parameters?

tobiascapin commented 1 week ago

Yes, sure! This is a ticketing sale service for theatres and cinemas. Every organizer has his own page of his own domain where all events are published. The structure, pages and the logic is almost the same for all organizers and this is why i could create an unique service just with a SiteSelectorMiddleware where I could handle all template customization (style, logo, images, etc.).

Now I need to handle some logic customization and I'm looking for a strong and flexible way to handle exceptions in controllers (actions).

For sure the simplest solution is to have something like this:

class MyAction{
    public function get(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
            if($host=="variantsite") {
                //do something customized
            }elseif($host=="variantsite2") {
                //do something else
            }else{
                //standard implementation
            }
    }
}

But I will need to put this if/else structure in many points and to be honest I don't like it as a long term solution...

I was wondering if it would be better to extend the controller and work it class inheritance like:

class MyAction{
    public function get(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        //standard implementation
        return $this->render($response, 'home.twig', []);
    }
}
class SubClassMyAction extends MyAction{
    public function get(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        //do something customized
        return parent::get($request,$response);
    }
}

And then I need to create a route mapping for standard implementation MyAction:

return function (App $app) {
    $app->get('/mypage', MyAction::class. ':get')->name("home");
    ....
}

But I also need to change the callable when the middleware detects a custom site, attaching the subclass callable instead of the standard one.

The current solution I found is to change the callable from the route collector using the route name as index and a static map top define custom classes by each site.

final class SiteSelectorMiddleware implements MiddlewareInterface{
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $customRoutes=[
             "variantsite"=>[
                    "home"=>SubClassMyAction::class.":get"
             ]
        ];

        $host=$request->getUri()->getHost();
        $request = $request->withAttribute('host', $host);

        if(array_key_exists($host,$customRoutes)){
             // Customized subclass callable for some routes of this site
             foreach($customRoutes[$host] as $routeName=>$callable){
                  $route=$this->app->getRouteCollector()->getNamedRoute($routeName);
                  $route->setCallable($callable);
             }
        }

        return $handler->handle($request);
    }
}

I don't know if exists any other better solution or if/else structure in many points is more advisable.

odan commented 1 week ago

I think this complex solution is not needed. Instead I would implement a Service class and handle that specific logic there. So you collect the parameters (e.g. the domain name or tenant) from the request object and then pass this as a parameter to a use-case specific Service class method. This service class then handles the core business logic and returns the domain result. Then pass the needed data from that result to your template engine for rendering.

Example:

final class MyAction
{
    private EventLister  $eventLister;

    public function __construct(EventLister $eventLister)
    {
        $this->eventLister = $eventLister;
    }

    public function post(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
    {
        $host = $request->getAttribute('host') ?? '';

        $events = $this->eventLister->getPublishedEvents($host);

        return $this->render($response, 'events.twig', $events);
    }
}

The EventLister class is a Service class that can handle all your custom logic (standard and custom).

Example:

final class EventLister
{
    private EventListerRepository  $repository;

    public function __construct(EventListerRepository $repository)
    {
        $this->repository= $repository;
    }

    public function getPublishedEvents(string $host): array
    {       
        // Input validation first
        // ...

        // then your custom logic here - if/else etc
        // ...

        $events = $this->repository->fetchPublishedEvents($host);

        return $events;
    }

So you don't need to extend any classes and can follow the SOLID principles.