On March 26, 2019 we launched new MODX Forums. Please join us at the new MODX Community Forums.
Subscribe: RSS
  • I was playing around with my UserUrls plugin to make it more flexible, and it turned into a very flexible friendly URL system. It’s pretty cool - in my current project, I have it generating 10 different friendly URL "schemas" at once (users, users/topics, 3 location types, location/ topics, just topics, and posts), covering 6 database tables, with currently 27 configurable settings for each URL schema.

    The code is on github: https://github.com/yoleg/CustomUrls

    It probably won’t be in the repository very soon, since the settings are currently stored in two complex JSON arrays (defaults and URL schemas), which are not very user friendly and need a CMP. I made an install script to generate the JSON arrays from two PHP arrays. In other words, it is ready to integrate into another component, but not quite for use by itself yet.

    Here’s what the plugin does:

    1. On OnPageNotFound, it loads CustomUrls as a service, parses the URL, saves the resulting schema object, sets a couple of REQUEST parameters, and shows the appropriate landing page.

    2. On OnLoadWebDocument, it checks if the page was accessed directly or through CustomUrls (and optionally redirects if accessed directly), retrieves and validates the stored URL schema, and optionally sets MODx placeholders from the database object(s) matching the schema.

    3. On OnWebPagePrerender, it str_replaces the current page’s URL with the current CustomUrl. This perfectly solves the problem of having to fix canonicals, breadcrumbs, and any other links to the current resource without interfering with GET parameters. You can also pass your own array of additional search and replace strings for each schema. I am purposefully staying away from any regular expressions, to keep everything efficient.

    URL structure:

    base_url.prefix.alias.suffix.delimiter.action-OR-child-schema

    Examples:

    • site.com/pre/fix/USERNAME.suffix (prefix = "pre/fix/", suffix = ".suffix", delimiter="/")
    • site.com/prefix-USERNAME-suffix/action (prefix = "prefix-", suffix = "-suffix", delimiter="/", action-map = {"action":"#"})
    • site.com/pre/fix/USERNAME/category/pre/fix/CATEGORY/category-action (example using child schema)

    Other stuff:

    The CustomUrls service is designed to integrate with other components, and includes "makeUrl" methods. "MakeUrl" and "GetInfo" or similar snippets are planned but not yet implemented. Finally, although you can "chain" multiple schemas with multiple tables, there is no support for joining tables - I recommend you generate your own "alias" columns for any tables that need to be joined. I will probably be including methods and example code to help with alias generation.

    Usage:

    With multiple schemas, there is a huge potential for conflicts. I recommend you use required prefixes and suffixes to quickly exclude schemas, validate your aliases to not conflict with each other, and carefully plan the order of the schemas (which are executed in the order they appear in the setting’s JSON array). Once a matching schema is found, no further schemas are checked except as "child schemas" of the matching schema.

    If using URL replacement, change the alias of the landing page to something complex and unique to avoid accidentally replacing legitimate text.

    If using placeholders, a "display" placeholder is provided to allow you to use a single placeholder anywhere - just stick it in the landing page’s "pagetitle" field like so: [[!+customurls.display]]. Tweak the settings to choose which field is used for display and which prefix it uses.

    The settings:

    Each "URL schema" is a representation of an array of settings. The 27 default settings are loaded first, then overridden by your custom default settings, then overridden by the settings for each particular schema. Here are the defaults and the custructor for the CustomUrls service:

    Code updated July 19, 2011 - check GitHub for newest version.

    <?php
    class customUrls {
    // ...
        public $defaults = array(
            'landing_resource_id' => 0,             // the resource id to redirect to
            'set_request' => true,                  // If true, sets $_REQUEST parameters
            'request_prefix' => 'user_',            // $_REQUEST parameter prefix
            'request_name_id' => 'id',              // $_REQUEST parameter for the value of the search_result_field
            'request_name_action' => 'action',      // $_REQUEST parameter for action (if found in the action map)
            'set_get' => true,                      // If true, sets $_GET parameters (using same settings as $_REQUEST)
            'base_url' => '',                       // NOT the system base_url, just a prefix to add to all generated URLs
            'lowercase_url' => true,                // Generates lowercase urls. Does not affect searches.
            'url_prefix' => '',                     // A prefix to append to the start of all urls
            'url_prefix_required' => true,          // Resolve the URL without the prefix?
            'url_suffix' => '',                     // A suffix to append to the end of all urls
            'url_suffix_required' => true,          // Resolve the URL without the suffix?
            'url_delimiter' => '/',                 // The separator between the main URL and the action
            'load_modx_service' => array(),         // loads as service if not empty. Must have the following keys set: 'name', 'class', 'package' OR 'path', and 'config' (config is an array). If you specify a lowercase package name, the path will be generated automatically for you based on either package.core_path setting or the default component structure.
            'search_class' => 'modUser',            // the xPDOObject class for the database table to search through
            'search_field' => 'username',           // the field to use in the URL
            'search_result_field' => 'id',          // the field to pass to the resource via the request_name_id
            'search_display_field' => '',           // the field to set in a special "display" placeholder to allow you to use the same placeholder for multiple schemas (defaults to search field)
            'search_where' => array('active' => 1), // an additional filter for the database query (xpdo where)
            'search_class_test_method' => '',       // a method name of the class to run. If resolves to false, will not continue. Useful for permissions checking.
            'action_map' => array(),                // an array of keys (action names) and values (resource ids) to use for the sub-actions
            'redirect_if_accessed_directly' => true,// will redirect to the error page if visited without CustomUrls
            'redirect_if_object_not_found' => true, // will redirect to the error page if the object is not found
            'set_placeholders' => true,             // will generate some placeholders on the page storing the object field values
            'placeholder_prefix' => 'customurls',   // the placeholder prefix to use if set_placeholders is true
            'display_placeholder' => 'display',     // the placeholder for the display value to use if set_placeholders is true
            'custom_search_replace' => array(),     // an array of search => replace pairs to str_replace the output with
            'run_without_parent' => true,           // if set to false, will not be run unless called by another schema in the child_schemas array
            'child_schemas' => array(),             // an array of schema_names OR schema_name => (array) overrides to run the URL remainder through
            'url_from_params' => true,              // if the page is accessed directly but with the proper GET parameters, the plugin will try to detect the schema from the GET or REQUEST params and forward to the friendly Url. Useful for Quip and similar components that redirect directly to the current page afterwards.
            'strict' => false,                      // "strict mode" if set to true, if any part of the URL is left over and not parsed, will treat the match as failed
            'children_inherit_landing' => false,    // if true, children schemas will use the landing page of the parent
        );
    //...
        function __construct(modX &$modx,array $config = array()) {
            $this->modx =& $modx;
            $this->config = $config;
            $defaults = $this->defaults;
            $custom_defaults = $this->modx->fromJSON($this->modx->getOption('customurls.defaults',null,'[]'));
            $defaults = array_merge($defaults,$custom_defaults);
            $this->defaults = $defaults;    // override stored defaults with new defaults
            $url_schemas = $this->modx->fromJSON($this->modx->getOption('customurls.schemas',null,'{"users":{"request_prefix":"uu_","request_name_id":"userid"}}'));
            $this->unprocessed = $url_schemas;
            // clean and register url schemas
            foreach ($url_schemas as $schema_name => $config) {
                // merge config with defaults
                $config = array_merge($defaults,$config);
                // register landing page in resource map
                $landing = $config['landing_resource_id'];
                if (empty($landing)) continue;
                $this->addLanding($landing,$schema_name);
                $config['key'] = $schema_name;
                // skip child-only schemas
                $top_level = $config['run_without_parent'];
                if (!$top_level) continue;
                // save the object
                // constructor also loads children into $schema->children and registers child pages
                $schema = new cuSchema($this,$config);
                $this->schemas[$schema_name] = $schema;
            }
        }
    //...
    


    Troubleshooting:

    Call the following snippet on the landing pages to see the placeholders and request parameters set:

    <?php
    /**
     * Outputs debugging info for MODx revolution
     * Author: Oleg Pryadko (oleg@websitezen.com)
     * License: public domain
     */
    $output = '';
    
    // not used - for syntax checking only
    global $modx;
    if (false) $modx = new modX('');
    
    // Display Errors
    error_reporting(E_ALL); ini_set('display_errors',true);
    $modx->setLogTarget('HTML');
    $modx->setLogLevel(modx::LOG_LEVEL_WARN);
    
    // display non-system placeholders
    $output_array = array();
    $array = $modx->placeholders;
    foreach ($array as $key => $value) {
        if (strpos($key,'+') !== 0) $output_array[$key] = $value;
    }
    $output .= '<pre>';
    $output .= '<br /><br />Placeholders: '.htmlentities(print_r($output_array,1));
    
    // display global arrays
    if (!empty($_REQUEST)) {
        $output .= '<br /><br />REQUEST: '.htmlentities(print_r($_REQUEST,1));
    }
    if (!empty($_POST)) {
        $output .= '<br /><br />POST: '.htmlentities(print_r($_POST,1));
    }
    if (!empty($_GET)) {
        $output .= '<br /><br />GET: '.htmlentities(print_r($_REQUEST,1));
    }
    $output .= '</pre>';
    
    return $output;
    

      WebsiteZen.com - MODX and E-Commerce web development in the San Francisco Bay Area
    • Hi Oleg, this looks awesome!

      I was about to start coding up something similar but may just give this a go instead. Looks like a very useful addition for those working with custom tables and data views.
      Will let you know how it goes with my project, thanks a lot for posting it smiley
      • Awesome, definitely looking forward to getting your feedback. You can post issues and feature requests to the GitHub issue tracker.

        Also, if you (or anyone else) want to contribute, I’ll make sure to add your info to the credits. You can post code or suggestions directly on the forum or initiate a GitHub pull request.
          WebsiteZen.com - MODX and E-Commerce web development in the San Francisco Bay Area
        • Hi,

          This indeed looks awesome! I can’t wait to get a "concrete" example to try it smiley
          Thanks for sharing this beauty.
          • Hi Oleg

            I have three tables each with an xPDO class and ’alias’ column: Region, Suburb and Estate.

            I need each to be accessible via the following urls:

            [tt] http://www.domain.com/region/suburb/estate
            http://www.domain.com/region/suburb
            http://www.domain.com/region[/tt]

            Of course, each segment needs to be validated to ensure child suburbs and estates are not accessed outside of their parent regions and suburbs.

            Can your plugin handle this or will I need to extend it with my own functions to handle the innerJoin queries needed?

            If it will work, could you point me in the right direction as to how to set this up?

            Cheers, Luke smiley
            • Sure - this is actually very easy to do, but you do need an extra column in each database table and one extra method for each of your classes. CustomUrls does not support multi-table joins or other complex conditions, since the configuration for that would get way too complex. Here are instructions for the way I did it with my geographical database:

              1. Add an extra column to each database table, such as "uri" or "alias2". If you did not create the tables and need to maintain a clean upgrade path, you will need to create a separate table called "Aliases" with the columns "alias", "class", and "id" and use that instead.

              2. Add a method to each class called "makeUrl". Whenever you are linking (or generating links to) an instance of the class, use the makeUrl method to generate the URL. The makeUrl method should be responsible for GENERATING and saving the URI to the database if it is empty ("region/suburb/estate" or "region/suburb", for example), as well as calling the CustomUrls service and using its makeUrl method to make the URL.

              3. Finally, configure CustomUrls as you normally would for a single table, using the new "uri" field as the search field. Just remember to change the "delimiter" to something other than "/", so that you can use slashes in your URI.

              Here is an example of a makeUrl method (note, I have not tested this or checked for errors):

              <?php
              class EstateClass extends xPDOSimpleObject {
                  //...
                  public function makeUrl ($schema_name = 'estates',$actions='',$children='') {
                      $friendly_urls = $this->xpdo->getOption('friendly_urls');
                      $output = '';
                      if ($friendly_urls) {
                          $uri = $this->get('uri');
                          // only if the URI has not yet been generated, generate and save it
                          if (empty($uri)) {
                              $suburb = $this->getOne('Suburb');
                              if (!($suburb instanceof SuburbClass)) return '';
                              $region = $suburb->getOne('Region');
                              if (!($region instanceof RegionClass)) return '';
                              $uri = $region->get('alias').'/'.$suburb->get('alias').'/'.$this->get('alias');
                              $this->set('uri',$uri);
                              if (!($this->save())) return '';
                          }
                          // load the customUrls instance as a MODx service
                          $customurls = $this->xpdo->getService('customurls','customUrls',$this->xpdo->getOption('customurls.core_path',null,$this->xpdo->getOption('core_path').'components/customurls/').'model/customurls/',array());
                          if (!($customurls instanceof customUrls)) {
                              $this->xpdo->log(modX::LOG_LEVEL_ERROR,'Could not load customurls.');
                          }
                          // generate the friendly URL
                          $output = $customurls->makeUrl($schema_name,$this,$actions,$children);
                      } else {
                          // generate a non-friendly URL with GET parameters if custom_urls are turned off system-wide
                      }
                      return $output;
                  }
                  //...
              }
              


                WebsiteZen.com - MODX and E-Commerce web development in the San Francisco Bay Area
              • Brilliant smiley Thanks so much for the detailed response... this is a really useful plugin!
                • Hey sirs,

                  While this looks awesome i’m lacking some knowledge/vocabulary to start using it. It would be kind if anyone could provide a basic "step-by-step" to set this up (learning from example is far more easy for me - i guess for some others too).
                  Much thanks wink
                  • Hopefully in a few weeks, I will create an install package. I am thinking of setting each "default" schema setting as its own system setting. That way, you will be able to easily configure at least a one-schema installation by changing the defaults.

                    For multiple-schema setup, though, you will still need to write or generate a JSON array, which is simple enough (although tedious) to do by hand.

                    Here are basic instructions for GitHub:

                    1. Upload the "core" folder to the root of your installation.
                    2. Create a new plugin called CustomUrls. Copy the contents of the customurls.plugin.php file into the plugin contents (https://github.com/yoleg/CustomUrls/blob/master/core/components/customurls/elements/plugins/customurls.plugin.php). Set the plugin to the following events at your desired priority level: ’OnPageNotFound’,’OnLoadWebDocument’,’OnWebPagePrerender’. Save the plugin (and make sure it is not disabled).
                    3. Create a new snippet called "Debug". Copy the contents of debug.snippet.php into it and save it (https://github.com/yoleg/CustomUrls/blob/master/core/components/customurls/elements/snippets/debug.snippet.php).
                    4. Choose a resource (document) that will be the landing page for the schemaand paste "[[!Debug]]" into the content and "[[!+customurls.display]] Page" into the title. Set the alias to something unique (since it will be replaced everywhere on the page). Save it and remember the resource id.
                    5. System settings: Make sure friendly URLS are enabled and working. Create two system settings (type textarea) called "customurls.defaults" and "customurls.schemas".
                    6. customurls.defaults: create the defaults JSON array. Paste the following into customurls.defaults (and replace 21 with your landing resource id). Many of these defaults are not necessary or match the default values, but are included to show you how to add more settings in. More of the available default settings are in the customurls class (model/customurls/customurls.class.php). Remember to escape slashes, quotes, and brackets!:
                    {"landing_resource_id":"21","url_delimiter":"\/","set_get":true,"custom_search_replace":{"<meta name=\"robots\" content=\"noindex,nofollow\" \/>":"<meta name=\"robots\" content=\"index,follow\" \/>"},"run_without_parent":true,"search_where":[],"strict":true,"children_inherit_landing":true}

                    7. customurls.schemas: create your schemas JSON array. Paste the following into customurls.schemas (set for compatibility with UserUrls). :
                    {"users":{"request_prefix":"uu_","request_name_id":"userid"}}


                    That’s it! If you have any experience with UserUrls, these settings should have configured the familiar UserUrls site.com/username setup. Try it out by linking to a username and seeing what placeholders and parameters are being set on the page via the Debug snippet.

                    More example code:

                    If you need more than one schema (say, for a custom component called MyArticles). As you can see, each schema has a name ("users", "articles"...) and an array of settings that override the defaults.

                    {
                        "users":{"landing_resource_id":"21","request_prefix":"uu_","request_name_id":"userid"},
                        "articles":{
                            "landing_resource_id":"22",
                            "request_prefix":"article_",
                            "request_name_id":"id",
                            "load_modx_service":{"name":"myarticles","class":"MyArticles","package":"myarticles","config":[]},
                            "search_class":"Article",
                            "url_prefix":"articles\/",
                            "search_field":"alias",
                            "search_class_test_method":"canView"
                        }
                    }
                    


                    Here is the code I use to auto-generate my settings via an install script:
                    <?php
                    $output = '';
                    $url_settings['customurls.defaults'] = array(
                        // PHP array of defaults
                    );
                    $url_settings['customurls.schemas'] = array();
                    $url_settings['customurls.schemas']['users'] = array(
                        // PHP array of settings for the "users" schema
                    );
                    $url_settings['customurls.schemas']['articles'] = array(
                        // PHP array of settings for the "articles" schema
                    );
                        foreach ($url_settings as $key => $value) {
                            $url_schemas_json = $modx->toJSON($value);
                            $setting = $modx->getObject('modSystemSetting',$key);
                            if ($setting === null) {
                                $setting = $modx->newObject('modSystemSetting',array('key' => $key));
                            }
                            if ($setting !== null) {
                                $setting->set('key',$key);
                                $setting->set('value',$url_schemas_json);
                                $setting->set('xtype','textarea');
                                $setting->set('namespace','userurls');
                                $setting->save();
                                $output .= 'created setting '.$key.': '.htmlentities($url_schemas_json);
                            }
                        }
                    return $output;
                    

                      WebsiteZen.com - MODX and E-Commerce web development in the San Francisco Bay Area
                    • Much thanks Oleg for your time and clear explanation, i now have some food to play with your plugin.
                      Just a small remark/question coming to my mind, how about generating some sitemap for those new pages ?
                      Edit: i guess writing a custom snippet should do the trick smiley

                      So everything went flawlessly while working with one table. I’m stuck when it comes to deal with multiple tables. Should i generate multiple settings or should i use the child_schemas setting ? I guess my case is almost the same as Lucas, i have only one schema with multiple tables :
                      <?xml version="1.0" encoding="UTF-8"?>
                      <model package="packageName" baseClass="xPDOObject" platform="mysql" defaultEngine="MyISAM" phpdoc-package="packageName">
                          <object class="categoryClass" table="table_name" extends="xPDOSimpleObject">
                           ...
                          </object>
                      
                          <object class="childClass" table="table_name" extends="xPDOSimpleObject">
                           ...
                          </object>
                      </model>