Users running a non-download version of Vanilla (pulled from github), on branch release/2019.016 or master from the last 2 weeks should upgrade to release/2019.017 or latest master for security reasons. Downloaded official open sources releases are not affected.

How to remove the discussion ID from the URL. Super clean pretty url!

edited August 2 in General Banter


In this thread I share a little idea/tut on how to create a tiny plugin to manage an app-level rerouting. This is only preferable for minimal use, say like what the plugin "BasicPages" has in mind. So, beware, cavete!!!

Like,, and so on.


I am playing with discussionModel_beforeSaveDiscussion_handler

Is it possible to get the id that vanilla has/will determine for a new discussion?

I am trying to get create a route, and need the id.




  • R_JR_J Cheerleader & Troubleshooter Munich Moderator

    Since it is the "before" save event, it is not possible. You need the AfterSaveDiscussion event for that. Maybe there is another approach? Could you describe in more detail what you are trying to achieve?

  • I have a script that manages rerouting super clean urls to my discussions.

    I removed the discussion ID from the url slug altogether and so this script internally checks the config for existing rules, conflicts and updates etc.

    All this works nicely for existing discussions, not so much for newly created ones.

    I need the id to set in Route target discussion/id$

    And I did try AfterSaveDiscussion before but it causes a big 500 error on the whole site, so I ran far away from that option. I tried to troubleshoot but it started to eat up all my time.

  • edited July 28

    Okay. I went back to crack my errors, seems I had two AfterSave functions firing. Working now.

    Thanks R_J for looking.

    All is well that ends well!

  • R_JR_J Cheerleader & Troubleshooter Munich Moderator

    How do you deal with discussions that have the same title?

  • charrondevcharrondev Application Developer (PHP, JS) Montreal Vanilla Staff

    It's definitely preferable to use the discussion ID. Not only is guaranteed to be unique, but it's also a very fast lookup. Is the plugin open source? I'm always curious to see different routing strategies people are using. We've quite a few discussions about routing internally recently.

  • Absolutely @charrondev . The purpose of this exercise was to allow part of the website act as ordinary site pages (about-us, faq etc), much like what this plugin does (BasicPages).

    The way routes are currently handled both in UI and in writing/saving to config.php makes it very cumbersome to desire/attempt to replace completely the existing routing procedure.

    One hundred entries and we would be heading to the pharmacy. Maybe in future, if this ever gets seriously requested, Vanilla could store this kind of thing in the DB, GDN_Discussion Table, and do some serious caching in the mapping, of course all the caveats still in place. Forums can easily rack up discussions. Unlike general CMSes.

    @R_J and @charrondev I added a new customPages field to the Categories table. If a discussion belongs there then the magic happens. Also, this customPages attribute is considered in the smarty template, so that these discussions can have a totally different look from the normal discussionisque style.

    Here is the function, maybe this can be posted in the tutorial section?

    It will not allow duplicate of route regardless of destination.

    It can allow multiple routes to same destination.

    It will check if user is changing the discussion name, delete the old

    public function discussionModel_afterSaveDiscussion_handler($sender) {
      $args = $sender->EventArguments;
      $this->setAutoRoute($args, 'new'); //fire only for new discussions
    public function discussionModel_beforeSaveDiscussion_handler($sender, $args) {
      $this->setAutoRoute($args); //fire only for existing iscussions
    private function setAutoRoute($args, $new = false){
      if(!$args['DiscussionID']){ //skip new discussions on discussionModel_beforeSaveDiscussion_handler
      if($args['DiscussionID'] && $new){ //skip existing discussions on discussionModel_afterSaveDiscussion_handler
      $discussionCategoryID = $args['FormPostValues']['CategoryID'];
      $discussionCategory = $this->getOneCategory($discussionCategoryID, 'CategoryID');
      $discussionID = $args['DiscussionID'];
      $isCustomPage = $discussionCategory['ActivateCustomPages'] ?? false; //new field in Categories Table
      $discussionUrlCode_old = Gdn_Format::Url($args['Discussion']->Name);
      $discussionUrlCode_new = Gdn_Format::Url($args['FormPostValues']['Name']);
      $routePrefix =  '';
      $routeSuffix =  '';
      $route_new = $routePrefix . $discussionUrlCode_new . $routeSuffix;
      $route_old = $routePrefix . $discussionUrlCode_old . $routeSuffix;
      $target = 'discussion/'.$discussionID;
      $matchRoute_new = Gdn::Router()->MatchRoute($route_new);
      $matchRoute_old = Gdn::Router()->MatchRoute($route_old);
      $existingRouteTarget = $this->getRouteByTarget($target);
      // $matchRoute_new["Route"] = "one-two-three"; //duplicate alias
      // $matchRoute_new["Destination"] = "discussion/56"; //duplicate target
      $canSetRouteOptions = ['swap', 'all_multiple', 'brand_new'];
      $canSetRoute = '';
        //send error only for when user alters discussion name
        if($matchRoute_new && $discussionUrlCode_old != $discussionUrlCode_new){
          die('Duplicate discussion name found in Routes');
          // return;
        //can make it optional to allow multiple routes with same target example-doc && site-faq both point to discusson/23
          //check if user is changing the discussion name
          if($discussionUrlCode_old != $discussionUrlCode_new){
              $canSetRoute = "swap";
            //check if the existing route is actually one that has our discussion name, if not create new one
            if($existingRouteTarget['Route'] != $route_new){
              $canSetRoute = "all_multiple";
          $canSetRoute = "brand_new";
      if(in_array($canSetRoute, $canSetRouteOptions)){
        // die($canSetRoute);
    private function getRouteByTarget($target){
      $routeArray = [];
      foreach (c('Routes') as $key => $value) {
        if($value[0] == $target){
          $key = base64_decode(str_replace('_', '/', $key));
          $routeArray['Route'] = $key;
          $routeArray['Destination'] = $value[0];
          return $routeArray;
      return false;
    private function getOneCategory($value, $field) {
      $categoryArray = CategoryModel::categories();
      $thisCategory = array();
      foreach ($categoryArray as $key => $category) {
        if ($category[$field] == $value && CategoryModel::checkPermission($category, 'Vanilla.Discussions.View')) { //check this
            $thisCategory = $category;
      return $thisCategory;

  • And of course all the other places where we need the new pretty urls would have to be altered, some setData() etc ...

    Most notably is the DiscussionUrl function.

    if (!function_exists('DiscussionUrl')) {
      function DiscussionUrl($Discussion, $Page = '', $WithDomain = TRUE) {
        $Discussion = (object)$Discussion;
        $catScan = new CategoryModel();
        $category = $catScan->getID($Discussion->CategoryID);
        $isCustomPage = $category->ActivateCustomPages; //new field in Categories Table
          $Result = Gdn_Format::Url($Discussion->Name);
          // previous code

    We might also need to do some cleanup when a discussion is deleted.

  • R_JR_J Cheerleader & Troubleshooter Munich Moderator

    The DiscussionModel has the following lines:

           $insert = $discussionID == '' ? true : false;
           $this->EventArguments['Insert'] = $insert;

    Therefore you can use it like that:

    public function discussionModel_afterSaveDiscussion_handler($sender, $args) {
    public function discussionModel_beforeSaveDiscussion_handler($sender, $args) {
    private function setAutoRoute($args, $new = false){
     // Skip new discussions

    I wouldn't add the "ActivateCustomPages" as a Category property but make that a config setting "YourPlugin.CategoryIDs" = '1,2,3'. That would allow a setting page to configure it easily.

    And without understanding your getRouteByTarget method: couldn't you use Gdn::router()->getRoute() or getDestination() for that purpose?

    You have chosen "Internal" as the redirection method. I would have chosen "301 permanent", since the discussion should never again be accessed as e.g. "discusion/123/about-us" but always as "about-us". But I don't know if this would really make any difference. It just feels "more exact"

  • R_JR_J Cheerleader & Troubleshooter Munich Moderator

    If not all discussions in a category get a new route, I would add the route as an information to the discussion attributes to be able to quickly check for any needed actions. That would avoid having to do route lookups.

  • edited August 5

    Thanks @R_J for the suggestions.

    IIRC, getDestination() does not get the whole route array. My function gets the array via the destination, so that I can check against the route name and other properties.

    I tried the 301 permanent, and to say the least, absolute disaster! The requests forwards to /discussion/id# totally ignoring the pretty url. And that stuff is pretty permanent, I know there is got to be a way to undo it, but it is not clear. My browser keeps forwarding even after I changed it in the vanilla settings and clean vanilla cache ...

    If not all discussions in a category get a new route, I would add the route as an information to the discussion attributes to be able to quickly check for any needed actions. That would avoid having to do route lookups.

    This I find super interesting ... I will have to try it.

    Thanks again.

  • R_JR_J Cheerleader & Troubleshooter Munich Moderator

    I just googled a bit and everyone who knows something about redirects says "use 302 if you are not 100% sure": sorry for my bad advice.

    301 redirects are cached in your browser but you might need to search the internet for how to remove those locally cached redirects from your browser. These seems to be non-trivial

  • charrondevcharrondev Application Developer (PHP, JS) Montreal Vanilla Staff

    The only way to properly clear a 301 is to issue a 301 in the opposite direction, they'll then cancel out for most clients. Be very careful with 301s though. The last thing you want is a crawler (like googlebot) to cache a bunch of 301s to the wrong place.

Sign In or Register to comment.