Building this blog, part 1 of 3

Running WordPress at /blog on a Laravel site

Nazmul Hasan Robin Avatar

6 min read

My site, nazmulrobin.com, is a Laravel app. I wanted a blog on it, and I wanted that blog to be WordPress, at nazmulrobin.com/blog and not on a subdomain.

This post is how I set that up on ordinary shared cPanel hosting. It is the first in a series about how this blog is built. The next two cover sharing posts on LinkedIn and publishing posts from Markdown files.

The short version

  • WordPress is installed in its own folder, outside the web root.
  • A symlink named blog inside the web root points at that folder.
  • Laravel’s own .htaccess already ignores real folders, so it never sees /blog.
  • Only my own code is in git. Plugins are installed by Composer, and a push to one branch deploys everything.

How a request finds the right app

This is the folder layout on the server:

~/public_html/            the web root, holds Laravel's public/ files
~/public_html/blog        a symlink to ../blog_wp
~/blog_wp/                the WordPress install
~/site_laravel/           the Laravel app

The reason this works is two lines that every Laravel install already ships in public/.htaccess:

RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]

Laravel only takes a request when the path is not a real file and not a real folder. /blog is a real folder, through the symlink, so Laravel leaves it alone.

Inside that folder WordPress has its own .htaccess. The web server uses the rewrite rules from the deepest .htaccess on the path, so pretty permalinks under /blog/ go to WordPress:

# BEGIN WordPress
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /blog/
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /blog/index.php [L]
</IfModule>
# END WordPress

WordPress writes that block itself when you save the permalink settings. It knows about the subfolder from its two URL settings, siteurl and home, which are both https://nazmulrobin.com/blog.

One thing to check on the Laravel side: it must not have a /blog route of its own. Mine does not.

Setting it up

  1. Create a database and a database user for the blog in cPanel.

  2. Install WordPress outside the web root. I used WP-CLI:

    mkdir ~/blog_wp && cd ~/blog_wp
    wp core download
    wp config create --dbname=... --dbuser=... --dbpass=...
    wp core install --url=https://example.com/blog --title="My Blog" \
      --admin_user=... --admin_email=...
    chmod 600 wp-config.php
  3. Add the symlink:

    ln -s ../blog_wp ~/public_html/blog
  4. Set the permalinks to “Post name” under Settings > Permalinks. This writes the .htaccess shown above.

That is all the routing needs. The blog now answers at /blog/.

Two small hardening steps

I added define( 'DISALLOW_FILE_EDIT', true ); to wp-config.php, which removes the theme and plugin file editors from wp-admin.

I also blocked readme.html, which tells anyone the WordPress version. The rule goes above the # BEGIN WordPress line, because WordPress rewrites everything between its own markers:

# BEGIN Block readme.html
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^readme\.html$ - [R=404,L]
</IfModule>
# END Block readme.html

Keeping the blog’s code in git

I did not want WordPress core, uploads or third-party plugins in my repository. The repo holds only what I wrote: a child theme, one site plugin, and the deploy files.

The .gitignore ignores everything and then allows a few paths back in:

/*
!/.gitignore
!/.github/
!/deploy.sh
!/README.md
!/composer.json
!/composer.lock
!/wp-content/
/wp-content/*
!/wp-content/mu-plugins/
!/wp-content/plugins/
/wp-content/plugins/*
!/wp-content/plugins/my-site-plugin/
!/wp-content/themes/
/wp-content/themes/*
!/wp-content/themes/my-theme/

Plugins come from Composer

Plugins are not installed from wp-admin. They are listed in composer.json and come from wpackagist, a Composer mirror of the WordPress.org plugin directory:

{
    "repositories": [
        { "type": "composer", "url": "https://wpackagist.org", "only": ["wpackagist-plugin/*"] }
    ],
    "require": {
        "composer/installers": "^2.3",
        "wpackagist-plugin/nhrrob-secure": "^2.2"
    },
    "extra": {
        "installer-paths": {
            "wp-content/plugins/{$name}/": ["type:wordpress-plugin"]
        }
    },
    "config": {
        "allow-plugins": { "composer/installers": true }
    }
}

composer.lock records the exact version of each plugin, so the server installs the versions I tested and nothing newer.

That only holds if WordPress does not update plugins behind my back. Otherwise WordPress would update one, and my next deploy would put the older, locked version back. A one-line must-use plugin turns auto-updates off:

<?php
/**
 * Plugin Name: Disable plugin auto-updates
 */

add_filter( 'auto_update_plugin', '__return_false' );

To update a plugin I run composer update wpackagist-plugin/<slug>, test it locally, and commit the lock file.

Deploying

The repo is cloned on the server next to the WordPress folder. Pushing to the production branch runs a GitHub Actions workflow that logs in over SSH and runs one script:

name: Deploy to Production

on:
  push:
    branches: [ "production" ]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
    - name: Deployment
      uses: appleboy/ssh-action@v1.2.0
      with:
        host: ${{ secrets.SSH_HOST }}
        username: ${{ secrets.SSH_USERNAME }}
        key: ${{ secrets.SSH_PRIVATE_KEY }}
        port: ${{ secrets.SSH_PORT }}
        script: cd ~/blog_repo && ./deploy.sh

The script pulls the branch, lets Composer install the locked plugins, and copies my theme, the must-use plugins and every plugin folder into the live WordPress install:

git checkout -f production
git pull origin production

composer install --no-dev --no-interaction --prefer-dist

for dir in wp-content/themes/my-theme wp-content/mu-plugins wp-content/plugins/*; do
    if [ -d "$dir" ]; then
        rm -rf "${WP_PATH:?}/$dir"
        cp -R "$dir" "$WP_PATH/$dir"
    fi
done

wp --path="$WP_PATH" cache flush

My host has no rsync, which is why it deletes and copies. The server pulls from GitHub with a read-only deploy key that is used for this one repo.

Things that caught me

  • The symlink is the whole connection. If the web root is ever wiped or copied over, the blog goes down until the blog symlink is back. The WordPress files are untouched, since they live outside it.
  • The deploy replaces folders. Anything edited directly in the live theme or in a plugin folder is lost on the next deploy. Changes go through git.
  • Composer installs files and nothing else. A new plugin still has to be activated once, and removing one from composer.json does not delete it from the live site.
  • A change to the deploy script takes two runs. The run that pulls the new script is still executing the old one.
  • Local development needs its own routing. I use Laravel Herd, which does not read .htaccess. A small custom Valet driver in the site folder sends anything under /blog to WordPress and the rest to Laravel.

That is the whole setup: one symlink for the routing, and a small repo that deploys itself.

Was this useful?
Nazmul Hasan Robin Avatar

Comments

One response to “Running WordPress at /blog on a Laravel site”

  1. […] This is the second post about how this blog is built. The first one covers running WordPress at /blog on a Laravel site. […]

Leave a Reply

Your email address will not be published. Required fields are marked *

Your first comment is reviewed before it appears. After that, your comments appear straight away.