
Hi folks,
For years my blog lived on its own subdomain, blog.hoja.in, with its own folder on the hosting and its own database. When I rebuilt hoja.in this year on TYPO3 14.3, I moved the blog to hoja.in/blog. One domain, one repository, and locally one DDEV project. I was a bit afraid of this, because TYPO3 and WordPress both want to own the URL space with their rewrite rules. It turned out simpler than expected. Here is the setup exactly as it runs on my Mac.
The layout
myport/
├── .ddev/
│ ├── config.yaml # TYPO3 project, generated by ddev config
│ └── config.myport.yaml # my additions
├── config/ # TYPO3 system and site configuration
├── public/ # web root
│ ├── .htaccess # TYPO3 rules
│ ├── index.php # TYPO3
│ └── blog/ # WordPress 7.1
│ ├── .htaccess # WordPress rules with RewriteBase /blog/
│ ├── wp-config.php
│ └── wp-config-ddev.php
├── Source/Extension/sitepackage/
└── composer.jsonWordPress is simply a folder inside the TYPO3 web root. Its core files are in git next to the TYPO3 project, the WordPress uploads are too, and the TYPO3 vendor/ folder is not.
Step 1: the DDEV config
The main file is what ddev config generated for a TYPO3 project, with PHP and the database pinned:
name: myport
type: typo3
docroot: public
php_version: "8.4"
webserver_type: apache-fpm
database:
type: mariadb
version: "10.11"
composer_version: "2"I chose apache-fpm on purpose. Both TYPO3 and WordPress ship .htaccess files, and the production hosting is Apache too. With nginx I would have to translate both rule sets and keep them in sync with production by hand. With Apache the same files work in both places.
My own additions are in a second file. DDEV reads every config.*.yaml in the folder and merges it, so I never touch the generated file:
disable_settings_management: true
web_environment:
- TYPO3_CONTEXT=Development/Ddev
hooks:
post-start:
- exec: mysql -uroot -proot -e "CREATE DATABASE IF NOT EXISTS blog CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; GRANT ALL ON blog.* TO 'db'@'%';"
service: dbThree things happen here. disable_settings_management stops DDEV from writing its own TYPO3 settings file, because my config/system/additional.php is in git and handles DDEV itself. TYPO3_CONTEXT lets the site configuration pick the local base URL. And the post-start hook creates a second database called blog for WordPress, with rights for the normal db user. It runs on every start, so IF NOT EXISTS is important. (My real file creates a third database, legacy, for the old TYPO3 7.6 data the import script read during the migration.)
Step 2: WordPress config per environment
WordPress has no environment handling like TYPO3’s context, so I split wp-config.php. The main file is in git and holds nothing secret:
<?php
if (getenv('IS_DDEV_PROJECT') === 'true') {
require __DIR__ . '/wp-config-ddev.php';
} else {
require __DIR__ . '/wp-config-production.php';
}
define('DB_CHARSET', 'utf8mb4');
define('DB_COLLATE', '');
$table_prefix = 'myblog_';
defined('WP_DEBUG') || define('WP_DEBUG', false);
if (!defined('ABSPATH')) {
define('ABSPATH', __DIR__ . '/');
}
require_once ABSPATH . 'wp-settings.php';DDEV sets IS_DDEV_PROJECT=true inside the containers. The DDEV file then points to the second database and the local URL:
define('DB_NAME', 'blog');
define('DB_USER', 'db');
define('DB_PASSWORD', 'db');
define('DB_HOST', 'db');
define('WP_HOME', 'https://hoja.in/blog');
define('WP_SITEURL', 'https://hoja.in/blog');
define('WP_ENVIRONMENT_TYPE', 'local');
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
define('WP_DEBUG_DISPLAY', false);
// plus the saltsThe production file has the real credentials and is in .gitignore. Old habit from the hosting days, but a good one.
Step 3: the rewrite rules
This was the part I worried about, and the answer is boring in a good way. Apache reads the .htaccess of the deepest directory. A request to /blog/some-post/ is handled by public/blog/.htaccess, and its rewrite rules replace the ones from the TYPO3 file one level up, because mod_rewrite does not inherit parent rules unless you ask for it. So the WordPress file only needs the right base:
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteBase /blog/
RewriteRule ^index.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /blog/index.php [L]
</IfModule>That is the standard block WordPress writes itself, with /blog/ instead of /. Everything outside /blog/ still goes to TYPO3. In TYPO3 the “Blog” menu entry is a page of the type Link pointing to /blog/.
Step 4: import and export both databases
DDEV’s database commands take a --database flag, so the blog database is handled like the main one:
# TYPO3 (default database "db")
ddev import-db --file=hojain_v14.sql.gz
ddev export-db --file=.project-migration/backups/pre-change.sql.gz
# WordPress (database "blog")
ddev import-db --database=blog --file=blog.sql
ddev export-db --database=blog --file=.project-migration/backups/blog-pre-change.sql.gz
# Both at once, as a DDEV snapshot of the whole database server
ddev snapshot --name=before-upgradeAfter importing an old blog dump, the URLs inside the database still point to the old domain. WP-CLI fixes that, and it is already inside the DDEV web container. Because my project type is typo3, I call it with ddev exec in the blog folder:
ddev exec --dir /var/www/html/public/blog wp search-replace
'https://blog.hoja.in' 'https://hoja.in/blog'
--all-tables-with-prefix --precise --dry-runRun it with --dry-run first and read the counts. Then without. search-replace handles serialized PHP arrays correctly, which a plain SQL REPLACE() would break.
Step 5: check both apps in one go
Two applications means two ways to break things. I keep a plain list of URLs in the repository, old TYPO3 pages and new ones, plus the blog front page and one blog post, and run them through curl after every change:
grep -v '^#' .project-migration/urls.txt | while read -r url; do
printf '%s %sn' "$(curl -sk -o /dev/null -w '%{http_code}' "$url")" "$url"
doneEvery line should start with 200. If a WordPress URL suddenly returns the TYPO3 404 page, the blog .htaccess is missing or has the wrong base. If a TYPO3 URL returns a WordPress page, something is very wrong with the document root. The check costs two seconds.
Mail is the other shared thing. DDEV catches every outgoing mail in Mailpit, so the TYPO3 contact form and WordPress comment notifications both land there instead of in a real inbox. That is how I tested the new EXT:form contact form of hoja.in before going anywhere near production.
What I would tell my younger self
- Use the same web server type locally as in production. It saves you from maintaining two rule sets.
- Put your DDEV additions in a separate
config.*.yaml. Updates of the generated file stay clean. - Use the post-start hook for extra databases, and make it idempotent.
- Dump before you import. Every time. My backups folder has a
pre-…dump for every step that changed data.
Two CMSs in one project sounds like trouble, but with DDEV it is just one ddev start in the morning. Cheers!