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
bloginside the web root points at that folder. - Laravel’s own
.htaccessalready 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
-
Create a database and a database user for the blog in cPanel.
-
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 -
Add the symlink:
ln -s ../blog_wp ~/public_html/blog -
Set the permalinks to “Post name” under Settings > Permalinks. This writes the
.htaccessshown 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
blogsymlink 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.jsondoes 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/blogto WordPress and the rest to Laravel.
That is the whole setup: one symlink for the routing, and a small repo that deploys itself.
Leave a Reply