Hosting An Advanced Yii2 App on Heroku

January 10, 2014

Yii2 is still not production ready, but I really wanted to take a crack at getting a test project up and running on Heroku, my host of choice. I think I've gotten a pretty good setup for the advanced application, but this is my first crack at it. I'll keep the blog updated with things I learn.

What is the Advanced Application Template?

Good question. Yii2 comes with a few application templates, basic and advanced. The basic template is much like Yii1.X, you'll find that one very familiar. The advanced template is a little bit different in that it separates the fronted and the backend components of your site. (Think of the fronted as your site, and the backend as the admin area.) At first I didn't like this method because it felt like there were too many configuration files that were unnecessary, but I've come to see it as both powerful and flexible. Use the advanced template, you'll like it.

Here's the problem though, in the docs they have you set up your local server in the following way:

  • for frontend /path/to/yii-application/frontend/web/ and using the URL http://frontend/
  • for backend /path/to/yii-application/backend/web/ and using the URL http://backend/

This is all well and good on your local machine, but when it comes time to push it to a virtual host, you are going to run into a few problems because you wont be able to do that. We're going to get around that by using the .htaccess file. We'll put "frontend" at root and "backend" at "/admin".

.Htaccess

Now, I'm no expert in .htaccess rules, but there are tons of resources out there that will help. The htaccess tester was super handy in trying to figure out all my stupid mistakes. That's just one resource, albeit my most used one, there are plenty of others that are a short Google search away.

Let me first show you my htaccess file, and then we'll walk through it.

<IfModule mod_rewrite.c>
    Options +FollowSymlinks
    RewriteEngine On
</IfModule>

<IfModule mod_rewrite.c>
    # deal with admin first
    RewriteCond %{REQUEST_URI} ^/(admin)
    RewriteRule ^admin/assets/(.*)$ backend/web/assets/$1 [L]
    RewriteRule ^admin/css/(.*)$ backend/web/css/$1 [L]

    RewriteCond %{REQUEST_URI} !^/backend/web/(assets|css)/
    RewriteCond %{REQUEST_URI} ^/(admin)
    RewriteRule ^.*$ backend/web/index.php [L]

    RewriteCond %{REQUEST_URI} ^/(assets|css)
    RewriteRule ^assets/(.*)$ frontend/web/assets/$1 [L]
    RewriteRule ^css/(.*)$ frontend/web/css/$1 [L]

    RewriteCond %{REQUEST_URI} !^/(frontend|backend)/web/(assets|css)/
    RewriteCond %{REQUEST_URI} !index.php
    RewriteCond %{REQUEST_FILENAME} !-f [OR]
    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteRule ^.*$ frontend/web/index.php
</IfModule>

Backend

The first IfModule part just tells Apache to turn the Rewrite Engine on. The real important stuff starts after the "deal with the admin first" comment. What we want to do is tell the server that anytime "/admin" is requested, we really are requesting "/backend/web/". There are three scenarios that we'll need to cover:

  • web/assets
  • web/css
  • everything else.

The first rewrite condition tests to see if the Request URI starts with "/admin". If it does, those two rules rewrite "admin/assets" and "admin/css" to "backend/web/assets" and "backend/web/css". This ensures that our admin site is pulling the correct assets. We'll talk about how to get Yii to request those URLs in a moment, for now, let's make sure the URLs are going to the correct place on the server.

The next set of rules makes sure that the Request URI isn't a backend asset URL and that it starts with "/admin". If those conditions are met, it reroutes the request to "backend/web/index.php", which is the Yii bootstrap file for the backend. Now we're all set up so that any request we make to "/admin" gets transferred to Yii. Off to a good start.

Frontend

Now to the frontend. We want the frontend to live at the root domain. Again the first condition checks to see if it's an asset URL, which will get rewritten to "frontend/web/assets" or "frontend/web/css" if it is. Then we move on to the "everything else category". There are a lot of conditions there to make sure we haven't already rewritten the URL to either a frontend or backend asset, and that it doesn't contain index.php. If it passes all of those tests, we rewrite it to "frontend/web/index.php", which will pass the request off to Yii.

What about the [L] Flag?

Yeah... so this is one of the things I learned when dealing with htaccess. The [L] flag does indeed prevent any more rules from being executed, but, it's only for that iteration. What I mean to say is this: If a rule is matched and an [L] flag encountered, no more rules will be processed and a new, rewritten URI will be in effect. This new, rewritten URI will then go back through all the rules. So the [L] flag does not mean "stop forever", it means "stop for this cycle". That's why we need to put conditions in to make sure that we don't either a) end up in an infinite loop or b) rewrite the URIs in a way that we aren't expecting. Confusing? I was certainly confused as to why it wasn't working, but now I know that it was working, I was just ignorant.

Getting Yii To Understand What Is Going On

Poor Yii. We're really confusing it at this point, we're going to help it out. After a lot of digging into the core Yii code, I've found a fairly simple way to make it work without mucking about with the core Yii (which means you can still use composer).

The way we're going to do this is to hijack the Request component and inject some of our own functions. Below you'll find the code, which we'll go through.

<?php

namespace common\components;

class Request extends \yii\web\Request {
    public $web;
    public $adminUrl;

    public function getBaseUrl(){
        return str_replace($this->web, "", parent::getBaseUrl()) . $this->adminUrl;
    }

    /*
        If you don't have this function, the admin site will 404 if you leave off
        the trailing slash.

        E.g.:

        Wouldn't work:
        site.com/admin

        Would work:
        site.com/admin/

        Using this function, both will work.
    */
    public function resolvePathInfo(){
        if($this->getUrl() === $this->adminUrl){
            return "";
        }else{
            return parent::resolvePathInfo();
        }
    }
}

Put this is your "common/components" folder and name it "Request.php". The component has two methods: "getBaseUrl" and "resolvePathInfo". The getBaseUrl method takes the URL from the parent class, and replaces the web variable with an empty string. In practice, this means that "frontend/web/assets/test.png" gets turned into "assets/test.png", which is what we want. Our htaccess file will take that URL and transform it back into "frontend/web/assets/test.png". Lots of shenanigans going on, but it makes it work and it makes it pretty.

The second method takes care of a curious phenomenon whereby if you request "/admin", Yii explodes. If you request "/admin/", you get to where you hope to be. I dug into the resolvePathInfo method on the parent and found that it was returning false instead of a blank string. This little bootstrap function will take care of that.

Installing the Component

Installation is pretty straightforward, in your main.php file of your frontend, add the following code in the components section:

'request'=>[
    'class' => 'common\components\Request',
    'web'=> '/frontend/web'
]

You'll want your 'web' to point to the location of your frontend web folder, which is "frontend/web" by default.

In the main.php file on your backend, add the following code to your components section

'request'=>[
    'class' => 'common\components\Request',
    'web'=> '/backend/web',
    'adminUrl' => '/admin'
]

You can change the 'adminUrl' to whatever you want it to be. If you do change it, you must change it in your htaccess file too, but that's super easy.

Dats All

Now you can push your repository up to Heroku and you'll have access to both your front and backends on the same domain. Like I said, this is my first crack at this and I may come across better ways to do it, or ways I've done it horribly. Please feel free to leave any suggestions in the comments.