Symfony foundation for a blog application with:
- public article listing and details
- admin area for managing content
- Doctrine entity, repository, form and service layer
- Twig templates and starter configuration
- migration workflow for the database schema
- authenticated admin access with role-based authorization
The project includes a small PHP-based development server helper.
Quick start:
- Install dependencies:
composer install - Create the SQLite database file:
composer db:create - Run migrations:
composer db:migrate - Start the development server:
composer serve:debug:start - Open the application:
http://127.0.0.1:8888
JavaScript runs in two modes:
dev: Twig loads source modules directly frompublic/assets/js/without minificationprod: Twig loads one bundled and minified file frompublic/assets/build/app.min.js
CSS also runs in two modes:
dev: Twig loadspublic/assets/css/styles.css, which imports all source files frompublic/assets/css/prod: build generates one minified file inpublic/assets/build/styles.min.css
Current CSS split is thematic:
public/assets/css/base.css: variables, reset, layout base, global backgroundpublic/assets/css/components/: smaller shared UI parts such astopbar,navigation,mobile-menu,footer,back-to-top,privacy,buttons,forms,flash,admin-shortcuts,editorpublic/assets/css/pages/: page-specific styles forhome,admin,blog,errorpublic/assets/css/responsive.css: shared responsive and reduced-motion rules
public/assets/css/styles.css is the shared CSS manifest: in development Twig loads it directly, and in production it is used as the bundler entrypoint that imports the smaller source stylesheets in the correct order.
Build commands:
- Install frontend dependencies:
npm install - Reproducible install from lockfile:
npm ci - Build production bundle with minification:
npm run build:assets:prod - Short production build alias:
npm run build:prod - Composer shortcut for production assets:
composer assets:build:prod
In daily development Twig serves JavaScript modules from public/assets/js/ and the CSS manifest from public/assets/css/styles.css, so no frontend build step is required for regular work.
Optional command:
- Build an unminified development bundle for manual inspection/debugging of the bundled output:
npm run build:assets:dev
Useful commands:
- Start server:
composer serve:debug:start - Check status:
composer serve:debug:status - Restart server:
composer serve:debug:restart - Stop server:
composer serve:debug:stop
The server writes its PID to:
Logs are written to:
The project includes PHPUnit-based unit tests for the domain and service layer:
- slug generation in
src/Service/ArticleSlugger.php - article save preparation in
src/Service/ArticlePublisher.php - inactive user blocking in
src/Security/UserChecker.php - selected behavior of
src/Entity/User.php
How to run them:
- Install project dependencies:
composer install - Run the test suite:
composer test
You can also run PHPUnit directly:
vendor/bin/phpunit --configuration phpunit.xml.dist
The unit tests do not require a database. Repository-dependent logic uses PHPUnit mocks.
The project includes Composer shortcuts for semantic version bumps and Git tagging after finishing work.
Available commands:
- Patch release:
composer release:patch - Minor release:
composer release:minor - Major release:
composer release:major - Patch release with push:
composer release:publish:patch - Minor release with push:
composer release:publish:minor - Major release with push:
composer release:publish:major
How it works:
- the command reads the latest semantic Git tag
- it calculates the next version based on
patch,minorormajor - it creates a new annotated Git tag on the current
HEAD - it stops if the Git working tree is not clean
- the
release:publish:*variants also push the current branch and the new tag toorigin
Supported tag formats:
v1.2.31.2.3- prerelease tags such as
1.2.3-beta-1orv1.2.3-rc.1
Notes:
- if no semantic tag exists yet, the first generated tag starts from
v0.0.1,v0.1.0orv1.0.0 release:publish:*requires an existingoriginremote and an active branch, not detachedHEADrelease:*can also be used on detachedHEAD; in that case it only creates the tag and suggests pushing just the tag manually- if you use
release:*without publish, push branch and tag manually, for example:git push origin feature/my-branch v0.0.1
Recommended flow:
- Commit your changes:
git add . && git commit -m "Describe changes" - Create the tag and push branch with tag in one step:
composer release:publish:patch
.
|-- bin/
|-- config/
| |-- packages/
| |-- bootstrap.php
| |-- bundles.php
| `-- routes.yaml
|-- migrations/
|-- public/
|-- src/
| |-- Command/
| |-- Controller/
| | `-- Admin/
| |-- Entity/
| |-- Enum/
| |-- Form/
| |-- Repository/
| |-- Security/
| `-- Service/
|-- templates/
| |-- admin/
| |-- blog/
| `-- security/
`-- var/
The project uses Doctrine Migrations. The schema is created by:
Typical workflow:
- Create the SQLite database file:
composer db:create - Run existing migrations:
composer db:migrate - After changing entities, generate a new migration:
composer db:migration:diff - Apply the new migration:
composer db:migrate
For SQLite, the database file is configured in:
Current value:
DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
In this project composer db:create creates the SQLite file directly. It does not call doctrine:database:create, because that operation is not supported here by the SQLite platform/DBAL combination.
If you see no such table: article or no such table: user, it means migrations have not been executed yet.
The admin area under /admin is protected by Symfony Security and requires ROLE_ADMIN.
Setup flow:
- Install packages:
composer install - Create the SQLite file:
composer db:create - Run migrations:
composer db:migrate - Create the first administrator:
composer user:create-admin - Open the login page:
/login
The create-admin command also accepts arguments:
php bin/console app:user:create-admin admin@example.com strong-password
Main security files:
- config/packages/security.yaml
- src/Entity/User.php
- src/Security/UserChecker.php
- src/Controller/SecurityController.php
- templates/security/login.html.twig
The admin panel includes a media module for uploading and browsing blog images.
Main views:
- upload form:
/admin/media - gallery:
/admin/media/gallery
How media storage works:
- uploaded image metadata is stored in the
media_imagedatabase table - files are written to dated subdirectories inside
public/uploads/media/ - the stored
file_pathin the database is treated as the source of truth for files managed by the gallery
Example storage layout:
public/uploads/media/2026/04/05/...
An orphaned media file is a file that still exists on disk in public/uploads/media/, but no longer has a matching row in the media_image table.
This can happen for example when:
- files were copied manually to the media directory
- database content was restored or changed without restoring matching files
- old development data left files on disk after database resets
To help clean this up, the project includes a console command:
php bin/console app:media:archive-orphansWhat the command does:
- scans
public/uploads/media/recursively - compares discovered files with
media_image.file_path - moves orphaned files to a temporary staging directory inside
var/ - creates a ZIP archive with the moved files
- prints the moved file list and generated archive path in the console output
Folders used by the command:
- media source directory:
public/uploads/media/ - temporary staging directory:
var/media-orphans/tmp/ - final archive directory:
var/media-orphans/
Ignored filenames:
- by default the command ignores
.gitkeep - the ignore list is configurable in config/services.yaml under
app.media_orphan_ignored_filenames - add more filenames there if your media directory contains placeholder or maintenance files that should never be archived
Archive naming:
var/media-orphans/media-orphans-YYYYMMDD-HHMMSS-<random>.zip
Notes:
- tracked files that have a matching
media_image.file_pathentry are left untouched - files whose basename is present in
app.media_orphan_ignored_filenamesare skipped even if they are not present in the database - if no orphaned files are found, the command exits successfully and reports that nothing had to be archived
- empty temporary directories created during the process are cleaned up automatically
- the command does not modify database rows; it only moves untracked files from disk and archives them
- because the archive is written under
var/, it is treated as runtime/generated data rather than user-facing media
The project includes background export queues for:
- articles
- article keywords
- article categories
- top menu
All generated files are stored in:
var/exports/
The admin panel includes:
- queue view:
/admin/queues/status - export history and download list:
/admin/exports
Flow overview:
- In the admin article list, exporting an article creates a pending record in
article_export_queue - The console consumer collects pending queue items and builds a JSON export file with article metadata and content
- After processing, the consumer creates a record in
article_exportwith status, type and file path - The exported article payload includes stable identifiers such as:
- article
slug - assigned category
category_slug
- article
Important files:
- src/Command/ProcessArticleExportQueueCommand.php
- src/Service/ArticleExportFileWriter.php
- src/Entity/ArticleExportQueue.php
- src/Entity/ArticleExport.php
- src/Controller/Admin/ArticleExportController.php
Manual run:
- Composer shortcut:
composer article-export:process-queue - Direct Symfony command:
php bin/console app:article-export:process-queue
What the manual run does:
- reads pending entries from
article_export_queue - sets them to
processing - generates a JSON export file in
var/exports - creates a new record in
article_export - marks processed queue entries as
completed
If there are no pending entries, the command exits successfully and prints:
No queued article exports to process.
Flow overview:
- In the admin category list, exporting a category creates a pending record in
category_export_queue - The console consumer builds a JSON export file with category metadata and translations
- The exported category payload includes a stable category
slug
Important files:
- src/Command/ProcessCategoryExportQueueCommand.php
- src/Service/CategoryExportFileWriter.php
- src/Entity/CategoryExportQueue.php
Manual run:
- Composer shortcut:
composer category-export:process-queue - Direct Symfony command:
php bin/console app:category-export:process-queue
Flow overview:
- In the admin article keyword view, exporting creates a pending record in
article_keyword_export_queue - The console consumer exports the full keyword dictionary to one JSON file
- The exported keyword payload includes stable identifiers and metadata such as:
- keyword
name - keyword
language - assigned
article_ids
- keyword
Important files:
- src/Command/ProcessArticleKeywordExportQueueCommand.php
- src/Service/ArticleKeywordExportFileWriter.php
- src/Entity/ArticleKeywordExportQueue.php
Manual run:
- Composer shortcut:
composer article-keyword-export:process-queue - Direct Symfony command:
php bin/console app:article-keyword-export:process-queue
Flow overview:
- In the admin top menu view, exporting creates a pending record in
top_menu_export_queue - The console consumer exports the full menu hierarchy to one JSON file
- The exported menu item payload includes stable identifiers for cross-environment matching:
- item
unique_name - parent
parent_unique_name - category target
category_slug - article target
article_slug
- item
Important files:
- src/Command/ProcessTopMenuExportQueueCommand.php
- src/Service/TopMenuExportFileWriter.php
- src/Entity/TopMenuExportQueue.php
Manual run:
- Composer shortcut:
composer top-menu-export:process-queue - Direct Symfony command:
php bin/console app:top-menu-export:process-queue
The repository includes a ready cron file:
Current entries process all background queues once per minute:
app:article-export:process-queueapp:category-export:process-queueapp:article-keyword-export:process-queueapp:top-menu-export:process-queueapp:article-import:process-queueapp:article-keyword-import:process-queueapp:category-import:process-queueapp:top-menu-import:process-queue
Before running consumers manually or from cron, make sure the database exists and migrations are applied:
- Install dependencies:
composer install - Create the SQLite database file:
composer db:create - Run migrations:
composer db:migrate
The project also includes an article import queue for processing previously exported JSON files in the background.
Flow overview:
- In the admin panel, uploading a file on
/admin/importscreates a pending record inarticle_import_queue - The console consumer reads the queued JSON export file and expects the
article-exportformat produced by the export mechanism - If an article with the same
slugalready exists, it is updated - If no article with that
slugexists, a new article is created - If validation fails or the file is invalid, the queue item is marked as
failedand the error reason is stored inerror_message - Uploaded files are stored in:
var/imports/ - The admin panel includes:
- upload and status list:
/admin/imports - pending queue view:
/admin/queues/status
- upload and status list:
Important files:
- src/Command/ProcessArticleImportQueueCommand.php
- src/Service/ArticleImportProcessor.php
- src/Entity/ArticleImportQueue.php
- src/Controller/Admin/ArticleImportController.php
Before running the import consumer manually, make sure the database exists and migrations are applied:
- Install dependencies:
composer install - Create the SQLite database file:
composer db:create - Run migrations:
composer db:migrate
Run the queue consumer manually with one of these commands:
- Composer shortcut:
composer article-import:process-queue - Direct Symfony command:
php bin/console app:article-import:process-queue
What the manual run does:
- reads pending entries from
article_import_queue - sets them to
processing - loads the uploaded JSON file from
var/imports - validates required fields and article constraints
- updates an existing article by
slugor creates a new one - marks successful items as
completed - marks invalid items as
failedand stores the reason inerror_message
If there are no pending entries, the command exits successfully and prints:
No queued article imports to process.
The project also includes a dedicated category import queue for restoring category data from exported JSON files in the background.
Flow overview:
- In the admin panel, uploading a file on
/admin/category-importscreates a pending record incategory_import_queue - The console consumer reads the
category-exportJSON format produced by the category export mechanism - Export payloads store category entries under the
categoriesarray key - If a category with the same
slugalready exists, it is updated - If no category with that
slugexists, a new category is created - If validation fails or the file is invalid, the queue item is marked as
failedand the error reason is stored inerror_message
Manual run:
- Composer shortcut:
composer category-import:process-queue - Direct Symfony command:
php bin/console app:category-import:process-queue
Important files:
- src/Command/ProcessCategoryImportQueueCommand.php
- src/Service/CategoryImportProcessor.php
- src/Entity/CategoryImportQueue.php
- src/Controller/Admin/CategoryImportController.php
The project also includes a dedicated article keyword import queue for restoring keyword data from exported JSON files.
Flow overview:
- In the admin panel, uploading a file on
/admin/article-keyword-importscreates a pending record inarticle_keyword_import_queue - The console consumer reads the
article-keyword-exportJSON format - Existing keywords are matched by the pair
language + name - If a keyword with the same
language + namealready exists, it is updated - If no keyword with that
language + nameexists, a new keyword is created - Keyword-to-article assignments from the import file are ignored, so current article links stay untouched
- If validation fails or the file is invalid, the queue item is marked as
failedand the error reason is stored inerror_message
Manual run:
- Composer shortcut:
composer article-keyword-import:process-queue - Direct Symfony command:
php bin/console app:article-keyword-import:process-queue
Important files:
- src/Command/ProcessArticleKeywordImportQueueCommand.php
- src/Service/ArticleKeywordImportProcessor.php
- src/Entity/ArticleKeywordImportQueue.php
- src/Controller/Admin/ArticleKeywordImportController.php
The project also includes a dedicated top menu import queue for restoring the menu hierarchy from exported JSON files.
Flow overview:
- In the admin panel, uploading a file on
/admin/top-menu/importscreates a pending record intop_menu_import_queue - The console consumer reads the
top-menu-exportJSON format - Existing items are matched by
unique_name - Parent relations are resolved by
parent_unique_name - Items are processed from the highest hierarchy level to the lowest so parent items are available before their children
- If validation fails or the file is invalid, the queue item is marked as
failedand the error reason is stored inerror_message
Manual run:
- Composer shortcut:
composer top-menu-import:process-queue - Direct Symfony command:
php bin/console app:top-menu-import:process-queue
For a local non-Docker setup you can use equivalent crontab entries, for example:
* * * * * cd /path/to/project && composer article-export:process-queue* * * * * cd /path/to/project && composer category-export:process-queue* * * * * cd /path/to/project && composer article-keyword-export:process-queue* * * * * cd /path/to/project && composer top-menu-export:process-queue* * * * * cd /path/to/project && composer article-import:process-queue* * * * * cd /path/to/project && composer article-keyword-import:process-queue* * * * * cd /path/to/project && composer category-import:process-queue* * * * * cd /path/to/project && composer top-menu-import:process-queue
- Add password reset and email verification.
- Split admin roles into editor and administrator.
- Add audit logging for content changes.
- Extend the domain with categories, tags, comments and media.
If you recreate the container and want to keep application data, mount the directories that store runtime files and uploads outside the container.
Required mounts:
- directory containing the SQLite
*.dbfile - mount the folder where the database file configured inDATABASE_URLis stored public/uploads/media/- keeps uploaded media library filespublic/uploads/avatars/- keeps uploaded user avatars
Optional mounts, if you also want to keep generated or uploaded helper files:
var/imports/- queued import files uploaded in the admin panelvar/exports/- generated export filesvar/media-orphans/- ZIP archives created byapp:media:archive-orphans
Example with bind mounts:
docker container run -d -p 8888:8888 -p 8080:80 \
-e APP_ENV=dev \
-e APP_DEBUG=1 \
-e APP_SECRET="test12345_deko1" \
-e DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" \
-v "$(pwd)/var:/var/www/app/var" \
-v "$(pwd)/public/uploads/media:/var/www/app/public/uploads/media" \
-v "$(pwd)/public/uploads/avatars:/var/www/app/public/uploads/avatars" \
--name blog-system-ai salamonrafal/blog-system-ai:devIf you use SQLite, make sure the mounted directory matches the current DATABASE_URL setting and points to the folder that contains the *.db file. In the default configuration this is var/, because the database file is stored as var/data.db. The upload directories should still be mounted separately so media files and avatars are not lost.
docker image build -t salamonrafal/blog-system-ai:dev .docker container run -d -p 8888:8888 -p 8080:80 \
-e APP_ENV=dev \
-e APP_DEBUG=1 \
-e APP_SECRET="test12345_deko1" \
-e DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" \
--name blog-system-ai salamonrafal/blog-system-ai:devdocker container exec -it blog-system-ai bashdocker container exec -u www-data -it blog-system-ai composer installdocker container exec -u www-data -it blog-system-ai php bin/console doctrine:migrations:migrate --no-interactiondocker container logs blog-system-aidocker container stop blog-system-ai && docker container rm blog-system-ai