How to publish articles, render the public blog, moderate comments, and override templates.
- Public pages
- Admin screens
- Comments
- Blog settings
- Hashtag sync command
- Published event
- Custom access logic
- Host meta providers
- Forms (FormKit + child loop)
- Twig overrides
- Translation overrides
- Infinite scroll assets
- Rich text notes
| Route name | Path | Purpose |
|---|---|---|
blog_index |
/blog |
Paginated or infinite list, search, tag filter |
blog_show |
/blog/{slug} |
Published article detail |
blog_comment_create |
POST /blog/{slug}/comments |
Public comment submit |
blog_comment_staff_reply |
POST /blog/comments/{id}/reply |
Staff reply (moderators) |
Only published articles with a non-empty body are visible on blog_show.
GET /blog?partial=1 returns the card partial used by infinite scroll.
| Route name | Path | Checker |
|---|---|---|
admin_blog_index |
/admin/blog |
canManage() + object listing filter |
admin_blog_new / admin_blog_edit |
/admin/blog/new, /admin/blog/{id}/edit |
canManage() + BlogKitAccessDenied on the article |
admin_blog_edit_modal / admin_blog_inline_update |
inline CMS modal | canManage() + BlogKitAccessDenied on the article |
admin_blog_delete |
POST /admin/blog/{id}/delete |
canManage() + BlogKitAccessDenied on the article |
admin_blog_tags_* |
/admin/blog/tags |
canManage() + BlogKitAccessDenied on mutations |
admin_blog_comments_* |
/admin/blog/comments |
canModerate() + BlogKitAccessDenied on mutations |
admin_blog_settings |
/admin/blog/settings |
canConfigure() |
List screens are paginated (web_ui.page_size). Deletes open a native <dialog> confirm with POST + CSRF in the footer.
Public visitors submit PublicBlogCommentType. New comments start pending and appear on the article only after a moderator approves them at /admin/blog/comments.
Comment spam controls are first-class:
- Rate limit — YAML
comments.rate_limit(fixed_windowper IP by default, 5 / 60s). Other strategies:per_ip_article,sliding_window,none, or a hostservice. - CAPTCHA — YAML
comments.captcha(honeypotby default). Alsorecaptcha_v2,recaptcha_v3,hcaptcha,turnstile,none, or a hostservice. - Operators can switch strategies (not secrets) at
/admin/blog/settings.
Staff replies use StaffBlogCommentReplyType on the public article (when canModerate() is true) or from the admin queue. The public reply POST also requires canModerate().
BlogSettings is a singleton edited at /admin/blog/settings. It controls listing mode (inherit / paginated / infinite), per-page size, card layout (inherit / masonry / grid / list), masonry columns (0 = YAML), card fields, aside placement, related-article limits, comments, share links, hero image mode, comment rate-limit / CAPTCHA strategies, and HTML sanitizer strategy.
YAML listing.mode (paginated or infinite) is the default when admin listing mode is inherit. YAML listing.masonry is the default when admin layout is inherit and column fields are 0. per_page is the page size and the infinite-scroll batch.
Trailing LinkedIn-style hashtags in article bodies can be formatted, turned into tags, and linked:
php bin/console nowo:blog:sync-hashtags
php bin/console nowo:blog:sync-hashtags --dry-runWhen an article transitions to published, BlogArticlePublishedDoctrineSubscriber dispatches BlogArticlePublishedEvent. Listen to it in the host application (for example to send Web Push via PwaBundle).
Role-based access is the default. For project-specific rules, implement BlogKitAccessCheckerInterface:
namespace App\Security;
use Nowo\BlogKitBundle\Security\BlogKitAccessCheckerInterface;
final class BlogEditorAccessChecker implements BlogKitAccessCheckerInterface
{
public function canManage(): bool
{
return true;
}
public function canModerate(): bool
{
return true;
}
public function canConfigure(): bool
{
return true;
}
}Register it:
nowo_blog_kit:
security:
access_checker: App\Security\BlogEditorAccessCheckerRoles decide who can open the admin UI. To scope publications (and optionally tags/comments) further, set security.object_access:
nowo_blog_kit:
security:
object_access:
strategy: owner # none | owner | serviceowner lets editors change articles they created (createdBy / AuditKit). Users with canConfigure() still see every row. Unowned legacy articles stay editable by any manager. Implement BlogKitResourceAccessCheckerInterface and set strategy: service for teams or workflow rules. Controllers throw via BlogKitAccessDenied. This is not a Symfony Security voter or ACL.
The extension prepends FormKit type_map.entity so BlogArticleType tags resolve. Hosts can still override nowo_form_kit.type_map.
Optional services:
BlogIndexMetaProviderInterface— title/description for the public indexBlogBrandNameProviderInterface— brand suffix for article<title>
Admin CRUD, public search, comment POST, and CSRF-only actions (delete / approve / reject) are Symfony Form Types (FormKit FormKitAbstractType / CsrfOnlyFormFactory). Twig renders them with form_start, the canonical child loop, and form_end (REQ-TWIG-003 / REQ-TWIG-005):
{{ form_start(form) }}
{% for child in form %}
{% if not child.rendered %}
{{ form_row(child) }}
{% endif %}
{% endfor %}
{{ form_end(form) }}Do not reintroduce raw <form> / <input> tags in host overrides. Delete confirms use a native <dialog> (@NowoUiKitBundle/partials/_confirm.html.twig) with the CSRF form in the footer. The inline CMS editor is a native <dialog> with header / content / actions (admin/_modal_form.html.twig).
Application templates under templates/bundles/NowoBlogKitBundle/ override the bundle copy for the same relative path (REQ-TWIG-001).
Common override targets:
templates/bundles/NowoBlogKitBundle/public/index.html.twigtemplates/bundles/NowoBlogKitBundle/public/show.html.twigtemplates/bundles/NowoBlogKitBundle/public/base.html.twigtemplates/bundles/NowoBlogKitBundle/public/layout.html.twigtemplates/bundles/NowoBlogKitBundle/admin/base.html.twigtemplates/bundles/NowoBlogKitBundle/admin/layout.html.twigtemplates/bundles/NowoBlogKitBundle/admin/index.html.twigtemplates/bundles/NowoBlogKitBundle/admin/_delete_confirm.html.twigtemplates/bundles/NowoBlogKitBundle/admin/_modal_form.html.twig
This precedence is installed by TwigPathsPass before the bundle view path is added.
Twig namespace: @NowoBlogKitBundle/... (REQ-TWIG-002).
UI strings use domain NowoBlogKitBundle. Override them in the host app:
translations/NowoBlogKitBundle.en.yaml
translations/NowoBlogKitBundle.es.yaml
See CONFIGURATION.md.
Published files (after assets:install / make assets):
asset('blog.css', 'nowo_blog_kit')asset('blog-kit.js', 'nowo_blog_kit')
Admin and public pages extend @NowoBlogKitBundle/admin/base.html.twig and @NowoBlogKitBundle/public/base.html.twig. Those bases call {{ parent() }} then load bundle CSS/JS in nested nowo_ui_styles / nowo_ui_scripts (REQ-UI-001). Point layout_template / public_layout_template at the host layout and set css_framework to bootstrap5, tailwind, foundation, or custom. Set icon_set and row_actions_display without copying list templates. Do not copy every page template.
nowo_blog_kit_container_class() adds the host container class (container / grid-container) next to the semantic blog-container wrapper.
blog-kit.js boots:
[data-controller="blog-infinite"](public masonry infinite scroll)[data-controller="form-collection"](article resource CollectionType)
No host Stimulus application is required. TypeScript sources live in src/Resources/assets/src/ and are built with Vite (pnpm run build).
Admin locale tabs use UiKit nowo-ui-tabs (asset('js/nowo-ui-tabs.js', 'nowo_ui_kit')).
public/show.html.twig renders article.body with |raw. Set html.sanitize.strategy to allowlist or strip (or a host sanitizer) so untrusted HTML is cleaned on save and on public render. none keeps trusted-editor HTML unchanged.
Comment bodies use auto-escaping plus |nl2br.