{"id":20,"date":"2026-10-12T09:00:00","date_gmt":"2026-10-12T03:00:00","guid":{"rendered":"https:\/\/nazmulrobin.com\/blog\/?p=20"},"modified":"2026-10-22T10:21:47","modified_gmt":"2026-10-22T04:21:47","slug":"laravel-and-wordpress-on-one-domain","status":"publish","type":"post","link":"https:\/\/nazmulrobin.com\/blog\/laravel-and-wordpress-on-one-domain\/","title":{"rendered":"Running WordPress at \/blog on a Laravel site"},"content":{"rendered":"\n<p>My site, nazmulrobin.com, is a Laravel app. I wanted a blog on it, and I wanted that blog to be WordPress, at <code>nazmulrobin.com\/blog<\/code> and not on a subdomain.<\/p>\n<p>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.<\/p>\n<h2>The short version<\/h2>\n<ul>\n<li>WordPress is installed in its own folder, outside the web root.<\/li>\n<li>A symlink named <code>blog<\/code> inside the web root points at that folder.<\/li>\n<li>Laravel&#8217;s own <code>.htaccess<\/code> already ignores real folders, so it never sees <code>\/blog<\/code>.<\/li>\n<li>Only my own code is in git. Plugins are installed by Composer, and a push to one branch deploys everything.<\/li>\n<\/ul>\n<h2>How a request finds the right app<\/h2>\n<p>This is the folder layout on the server:<\/p>\n<pre><code>~\/public_html\/            the web root, holds Laravel's public\/ files\n~\/public_html\/blog        a symlink to ..\/blog_wp\n~\/blog_wp\/                the WordPress install\n~\/site_laravel\/           the Laravel app<\/code><\/pre>\n<p>The reason this works is two lines that every Laravel install already ships in <code>public\/.htaccess<\/code>:<\/p>\n<pre><code class=\"language-apache hljs\"><span class=\"hljs-attribute\"><span class=\"hljs-nomarkup\">RewriteCond<\/span><\/span> <span class=\"hljs-variable\">%{REQUEST_FILENAME}<\/span> !-d\n<span class=\"hljs-attribute\"><span class=\"hljs-nomarkup\">RewriteCond<\/span><\/span> <span class=\"hljs-variable\">%{REQUEST_FILENAME}<\/span> !-f\n<span class=\"hljs-attribute\"><span class=\"hljs-nomarkup\">RewriteRule<\/span><\/span> ^ index.php<span class=\"hljs-meta\"> [L]<\/span><\/code><\/pre>\n<p>Laravel only takes a request when the path is not a real file and not a real folder. <code>\/blog<\/code> is a real folder, through the symlink, so Laravel leaves it alone.<\/p>\n<p>Inside that folder WordPress has its own <code>.htaccess<\/code>. The web server uses the rewrite rules from the deepest <code>.htaccess<\/code> on the path, so pretty permalinks under <code>\/blog\/<\/code> go to WordPress:<\/p>\n<pre><code class=\"language-apache hljs\"><span class=\"hljs-comment\"># BEGIN WordPress<\/span>\n<span class=\"hljs-section\">&lt;IfModule mod_rewrite.c&gt;<\/span>\n<span class=\"hljs-attribute\"><span class=\"hljs-nomarkup\">RewriteEngine<\/span><\/span> <span class=\"hljs-literal\">On<\/span>\n<span class=\"hljs-attribute\">RewriteBase<\/span> \/blog\/\n<span class=\"hljs-attribute\"><span class=\"hljs-nomarkup\">RewriteRule<\/span><\/span> ^index\\.php$ -<span class=\"hljs-meta\"> [L]<\/span>\n<span class=\"hljs-attribute\"><span class=\"hljs-nomarkup\">RewriteCond<\/span><\/span> <span class=\"hljs-variable\">%{REQUEST_FILENAME}<\/span> !-f\n<span class=\"hljs-attribute\"><span class=\"hljs-nomarkup\">RewriteCond<\/span><\/span> <span class=\"hljs-variable\">%{REQUEST_FILENAME}<\/span> !-d\n<span class=\"hljs-attribute\"><span class=\"hljs-nomarkup\">RewriteRule<\/span><\/span> . \/blog\/index.php<span class=\"hljs-meta\"> [L]<\/span>\n<span class=\"hljs-section\">&lt;\/IfModule&gt;<\/span>\n<span class=\"hljs-comment\"># END WordPress<\/span><\/code><\/pre>\n<p>WordPress writes that block itself when you save the permalink settings. It knows about the subfolder from its two URL settings, <code>siteurl<\/code> and <code>home<\/code>, which are both <code>https:\/\/nazmulrobin.com\/blog<\/code>.<\/p>\n<p>One thing to check on the Laravel side: it must not have a <code>\/blog<\/code> route of its own. Mine does not.<\/p>\n<h2>Setting it up<\/h2>\n<ol>\n<li>\n<p><strong>Create a database<\/strong> and a database user for the blog in cPanel.<\/p>\n<\/li>\n<li>\n<p><strong>Install WordPress outside the web root.<\/strong> I used WP-CLI:<\/p>\n<pre><code class=\"language-sh hljs\">mkdir ~\/blog_wp &amp;&amp; <span class=\"hljs-built_in\">cd<\/span> ~\/blog_wp\nwp core download\nwp config create --dbname=... --dbuser=... --dbpass=...\nwp core install --url=https:\/\/example.com\/blog --title=<span class=\"hljs-string\">\"My Blog\"<\/span> \\\n  --admin_user=... --admin_email=...\nchmod 600 wp-config.php<\/code><\/pre>\n<\/li>\n<li>\n<p><strong>Add the symlink:<\/strong><\/p>\n<pre><code class=\"language-sh hljs\">ln -s ..\/blog_wp ~\/public_html\/blog<\/code><\/pre>\n<\/li>\n<li>\n<p><strong>Set the permalinks<\/strong> to &#8220;Post name&#8221; under Settings &gt; Permalinks. This writes the <code>.htaccess<\/code> shown above.<\/p>\n<\/li>\n<\/ol>\n<p>That is all the routing needs. The blog now answers at <code>\/blog\/<\/code>.<\/p>\n<h2>Two small hardening steps<\/h2>\n<p>I added <code>define( 'DISALLOW_FILE_EDIT', true );<\/code> to <code>wp-config.php<\/code>, which removes the theme and plugin file editors from wp-admin.<\/p>\n<p>I also blocked <code>readme.html<\/code>, which tells anyone the WordPress version. The rule goes above the <code># BEGIN WordPress<\/code> line, because WordPress rewrites everything between its own markers:<\/p>\n<pre><code class=\"language-apache hljs\"><span class=\"hljs-comment\"># BEGIN Block readme.html<\/span>\n<span class=\"hljs-section\">&lt;IfModule mod_rewrite.c&gt;<\/span>\n<span class=\"hljs-attribute\"><span class=\"hljs-nomarkup\">RewriteEngine<\/span><\/span> <span class=\"hljs-literal\">On<\/span>\n<span class=\"hljs-attribute\"><span class=\"hljs-nomarkup\">RewriteRule<\/span><\/span> ^readme\\.html$ -<span class=\"hljs-meta\"> [R=404,L]<\/span>\n<span class=\"hljs-section\">&lt;\/IfModule&gt;<\/span>\n<span class=\"hljs-comment\"># END Block readme.html<\/span><\/code><\/pre>\n<h2>Keeping the blog&#8217;s code in git<\/h2>\n<p>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.<\/p>\n<p>The <code>.gitignore<\/code> ignores everything and then allows a few paths back in:<\/p>\n<pre><code>\/*\n!\/.gitignore\n!\/.github\/\n!\/deploy.sh\n!\/README.md\n!\/composer.json\n!\/composer.lock\n!\/wp-content\/\n\/wp-content\/*\n!\/wp-content\/mu-plugins\/\n!\/wp-content\/plugins\/\n\/wp-content\/plugins\/*\n!\/wp-content\/plugins\/my-site-plugin\/\n!\/wp-content\/themes\/\n\/wp-content\/themes\/*\n!\/wp-content\/themes\/my-theme\/<\/code><\/pre>\n<h2>Plugins come from Composer<\/h2>\n<p>Plugins are not installed from wp-admin. They are listed in <code>composer.json<\/code> and come from <a href=\"https:\/\/wpackagist.org\">wpackagist<\/a>, a Composer mirror of the WordPress.org plugin directory:<\/p>\n<pre><code class=\"language-json hljs\">{\n    <span class=\"hljs-attr\">\"repositories\"<\/span>: [\n        { <span class=\"hljs-attr\">\"type\"<\/span>: <span class=\"hljs-string\">\"composer\"<\/span>, <span class=\"hljs-attr\">\"url\"<\/span>: <span class=\"hljs-string\">\"https:\/\/wpackagist.org\"<\/span>, <span class=\"hljs-attr\">\"only\"<\/span>: [<span class=\"hljs-string\">\"wpackagist-plugin\/*\"<\/span>] }\n    ],\n    <span class=\"hljs-attr\">\"require\"<\/span>: {\n        <span class=\"hljs-attr\">\"composer\/installers\"<\/span>: <span class=\"hljs-string\">\"^2.3\"<\/span>,\n        <span class=\"hljs-attr\">\"wpackagist-plugin\/nhrrob-secure\"<\/span>: <span class=\"hljs-string\">\"^2.2\"<\/span>\n    },\n    <span class=\"hljs-attr\">\"extra\"<\/span>: {\n        <span class=\"hljs-attr\">\"installer-paths\"<\/span>: {\n            <span class=\"hljs-attr\">\"wp-content\/plugins\/{$name}\/\"<\/span>: [<span class=\"hljs-string\">\"type:wordpress-plugin\"<\/span>]\n        }\n    },\n    <span class=\"hljs-attr\">\"config\"<\/span>: {\n        <span class=\"hljs-attr\">\"allow-plugins\"<\/span>: { <span class=\"hljs-attr\">\"composer\/installers\"<\/span>: <span class=\"hljs-literal\">true<\/span> }\n    }\n}<\/code><\/pre>\n<p><code>composer.lock<\/code> records the exact version of each plugin, so the server installs the versions I tested and nothing newer.<\/p>\n<p>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:<\/p>\n<pre><code class=\"language-php hljs\"><span class=\"hljs-meta\">&lt;?php<\/span>\n<span class=\"hljs-comment\">\/**\n * Plugin Name: Disable plugin auto-updates\n *\/<\/span>\n\nadd_filter( <span class=\"hljs-string\">'auto_update_plugin'<\/span>, <span class=\"hljs-string\">'__return_false'<\/span> );<\/code><\/pre>\n<p>To update a plugin I run <code>composer update wpackagist-plugin\/&lt;slug&gt;<\/code>, test it locally, and commit the lock file.<\/p>\n<h2>Deploying<\/h2>\n<p>The repo is cloned on the server next to the WordPress folder. Pushing to the <code>production<\/code> branch runs a GitHub Actions workflow that logs in over SSH and runs one script:<\/p>\n<pre><code class=\"language-yaml hljs\"><span class=\"hljs-attr\">name:<\/span> <span class=\"hljs-string\">Deploy<\/span> <span class=\"hljs-string\">to<\/span> <span class=\"hljs-string\">Production<\/span>\n\n<span class=\"hljs-attr\">on:<\/span>\n  <span class=\"hljs-attr\">push:<\/span>\n    <span class=\"hljs-attr\">branches:<\/span> <span class=\"hljs-string\">[<\/span> <span class=\"hljs-string\">\"production\"<\/span> <span class=\"hljs-string\">]<\/span>\n\n<span class=\"hljs-attr\">jobs:<\/span>\n  <span class=\"hljs-attr\">deploy:<\/span>\n    <span class=\"hljs-attr\">runs-on:<\/span> <span class=\"hljs-string\">ubuntu-latest<\/span>\n    <span class=\"hljs-attr\">steps:<\/span>\n    <span class=\"hljs-bullet\">-<\/span> <span class=\"hljs-attr\">name:<\/span> <span class=\"hljs-string\">Deployment<\/span>\n      <span class=\"hljs-attr\">uses:<\/span> <span class=\"hljs-string\">appleboy\/ssh-action@v1.2.0<\/span>\n      <span class=\"hljs-attr\">with:<\/span>\n        <span class=\"hljs-attr\">host:<\/span> <span class=\"hljs-string\">${{<\/span> <span class=\"hljs-string\">secrets.SSH_HOST<\/span> <span class=\"hljs-string\">}}<\/span>\n        <span class=\"hljs-attr\">username:<\/span> <span class=\"hljs-string\">${{<\/span> <span class=\"hljs-string\">secrets.SSH_USERNAME<\/span> <span class=\"hljs-string\">}}<\/span>\n        <span class=\"hljs-attr\">key:<\/span> <span class=\"hljs-string\">${{<\/span> <span class=\"hljs-string\">secrets.SSH_PRIVATE_KEY<\/span> <span class=\"hljs-string\">}}<\/span>\n        <span class=\"hljs-attr\">port:<\/span> <span class=\"hljs-string\">${{<\/span> <span class=\"hljs-string\">secrets.SSH_PORT<\/span> <span class=\"hljs-string\">}}<\/span>\n        <span class=\"hljs-attr\">script:<\/span> <span class=\"hljs-string\">cd<\/span> <span class=\"hljs-string\">~\/blog_repo<\/span> <span class=\"hljs-string\">&amp;&amp;<\/span> <span class=\"hljs-string\">.\/deploy.sh<\/span><\/code><\/pre>\n<p>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:<\/p>\n<pre><code class=\"language-sh hljs\">git checkout -f production\ngit pull origin production\n\ncomposer install --no-dev --no-interaction --prefer-dist\n\n<span class=\"hljs-keyword\">for<\/span> dir <span class=\"hljs-keyword\">in<\/span> wp-content\/themes\/my-theme wp-content\/mu-plugins wp-content\/plugins\/*; <span class=\"hljs-keyword\">do<\/span>\n    <span class=\"hljs-keyword\">if<\/span> [ -d <span class=\"hljs-string\">\"<span class=\"hljs-variable\">$dir<\/span>\"<\/span> ]; <span class=\"hljs-keyword\">then<\/span>\n        rm -rf <span class=\"hljs-string\">\"<span class=\"hljs-variable\">${WP_PATH:?}<\/span>\/<span class=\"hljs-variable\">$dir<\/span>\"<\/span>\n        cp -R <span class=\"hljs-string\">\"<span class=\"hljs-variable\">$dir<\/span>\"<\/span> <span class=\"hljs-string\">\"<span class=\"hljs-variable\">$WP_PATH<\/span>\/<span class=\"hljs-variable\">$dir<\/span>\"<\/span>\n    <span class=\"hljs-keyword\">fi<\/span>\n<span class=\"hljs-keyword\">done<\/span>\n\nwp --path=<span class=\"hljs-string\">\"<span class=\"hljs-variable\">$WP_PATH<\/span>\"<\/span> cache flush<\/code><\/pre>\n<p>My host has no <code>rsync<\/code>, 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.<\/p>\n<h2>Things that caught me<\/h2>\n<ul>\n<li><strong>The symlink is the whole connection.<\/strong> If the web root is ever wiped or copied over, the blog goes down until the <code>blog<\/code> symlink is back. The WordPress files are untouched, since they live outside it.<\/li>\n<li><strong>The deploy replaces folders.<\/strong> Anything edited directly in the live theme or in a plugin folder is lost on the next deploy. Changes go through git.<\/li>\n<li><strong>Composer installs files and nothing else.<\/strong> A new plugin still has to be activated once, and removing one from <code>composer.json<\/code> does not delete it from the live site.<\/li>\n<li><strong>A change to the deploy script takes two runs.<\/strong> The run that pulls the new script is still executing the old one.<\/li>\n<li><strong>Local development needs its own routing.<\/strong> I use Laravel Herd, which does not read <code>.htaccess<\/code>. A small custom Valet driver in the site folder sends anything under <code>\/blog<\/code> to WordPress and the rest to Laravel.<\/li>\n<\/ul>\n<p>That is the whole setup: one symlink for the routing, and a small repo that deploys itself.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>My site is a Laravel app, and this blog is WordPress living at \/blog on the same domain. Here is how the two share one domain on shared hosting, and how the blog&#8217;s code is kept in git and deployed.<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[7],"tags":[8,10,11,9],"class_list":["post-20","post","type-post","status-publish","format-standard","hentry","category-wordpress","tag-building-this-blog","tag-composer","tag-deployment","tag-laravel"],"_links":{"self":[{"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/posts\/20","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/comments?post=20"}],"version-history":[{"count":2,"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/posts\/20\/revisions"}],"predecessor-version":[{"id":55,"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/posts\/20\/revisions\/55"}],"wp:attachment":[{"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/media?parent=20"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/categories?post=20"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/tags?post=20"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}