elvandar / kazetenn
Content management framework for Symfony
Requires
- php: >=8.4
- doctrine/doctrine-bundle: ^3.0
- doctrine/orm: ^3.3
- symfony/asset: ^8.0
- symfony/cache: ^8.0
- symfony/console: ^8.0
- symfony/dotenv: ^8.0
- symfony/form: ^8.0
- symfony/framework-bundle: ^8.0
- symfony/mime: ^8.0
- symfony/monolog-bundle: ^4.0
- symfony/rate-limiter: ^8.0
- symfony/runtime: ^8.0
- symfony/security-bundle: ^8.0
- symfony/security-core: ^8.0
- symfony/security-csrf: ^8.0
- symfony/security-http: ^8.0
- symfony/twig-bundle: ^8.0
- symfony/uid: ^8.0
- symfony/validator: ^8.0
- symfony/yaml: ^8.0
- twig/twig: ^3.0
Requires (Dev)
- deptrac/deptrac: ^4.6
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^12.0
- symfony/browser-kit: ^8.0
- symfony/css-selector: ^8.0
- symfony/debug-bundle: ^8.0
- symfony/stopwatch: ^8.0
README
A content management framework for Symfony.
Declare a content type as a single PHP class, and Kazetenn handles the persistence, the admin CRUD, the public site with RSS and search — no code generation, no boilerplate and the ability to keep using your object as usual.
Requirements
- PHP 8.4+
- Symfony 8
- Doctrine ORM 3 with PostgreSQL (SQLite for tests).
Status
0.1.x — early, and maintained as a side project.
Breaking changes: yes. Kazetenn currently runs one production site: mine.
Issues and pull requests are welcome, but I make no commitment on response time or on accepting a given feature.
The idea, in one class
use Kazetenn\Core\Domain\Attribute\ContentType;
use Kazetenn\Core\Domain\Attribute\Field;
use Kazetenn\Core\Domain\Content\Content;
use Kazetenn\Core\Domain\Content\FieldType;
use Symfony\Component\Validator\Constraints as Assert;
#[ContentType('article')]
class Article extends Content
{
#[Field(FieldType::RichText)]
#[Assert\NotBlank]
public string $text = '';
#[Field(FieldType::Tags)]
public array $tags = [];
}
From this single class, you will get:
- A database table
article(one table per content type, mapped by a custom Doctrine driver — no generated entity, using migrations is your choice); - An admin at
/admin: login, plus a create/edit/delete form built by reflection on your#[Field]attributes — a rich-text editor forRichText, a tag input forTags, and so on, with your Symfony validation constraints enforced; - A public display: a list at
/article, each item at/article/{slug}, an RSS feed at/article/rss, and search — with HTTP caching throughout; - Tag filtering, a media library, and drafts that stay private until you publish.
Everything beyond your own fields comes from the base Content class — every type
inherits it for free, accessed through getters/setters rather than written by hand:
| Inherited | Type | Notes |
|---|---|---|
id | string | UUIDv7, generated when the object is created — no setter |
title | string | |
slug | string | the URL segment: /{type}/{slug}. editable, auto-filled by the admin form |
status | ContentStatus | Draft (default) or Published — only Published is ever public |
createdAt | DateTimeImmutable | stamped at creation |
updatedAt | DateTimeImmutable | refreshed automatically on every save |
publishedAt | ?DateTimeImmutable | null until you first publish, then set automatically |
getType() returns the name declared in #[ContentType]. In your subclass you only
declare the fields specific to your type — as with text and tags above.
Features
- One table per content type — a custom Doctrine mapping driver, no generated entity to maintain.
- Admin CRUD reflected from your attributes — forms, widgets and validation built from
#[Field], nothing wired by hand. - Rich text & a media library — bundled editor, uploads validated by real MIME type (
finfo, never the clientContent-Type), served straight off disk. - Tags — portable storage and a native tag input, no separate table; filter public listings by tag.
- Public site with RSS & search — list, detail, per-type feed (toggleable) and search, with HTTP caching (
ETag/Last-Modified) throughout. - Draft / published workflow — only published content is ever public.
- Secure by default — throttled login, CSRF on every form and AJAX endpoint.
- No CDN — Bulma, Stimulus and the editor vendored in-repo.
Getting started
Kazetenn's Flex recipe is served from its own endpoint (it is not on
symfony/recipes-contrib), so first point Flex at it in your composer.json — keep
flex://defaults so the standard Symfony recipes still apply:
{
"extra": {
"symfony": {
"endpoint": [
"https://kazetenn-c37fe4.gitlab.io/index.json",
"flex://defaults"
]
}
}
}
Then install:
composer require elvandar/kazetenn
With Symfony Flex, the recipe registers the four bundles, imports the admin and display routes, and drops in a starter configuration and the admin security firewall.
One manual step the recipe can't do for you. If your
config/routes.yamluses the Symfony skeleton's catch-all loader (controllers: resource: routing.controllers), scope it to your own controllers — otherwise it also discovers Kazetenn's controllers and re-registers them without their route prefix, silently: you get/logininstead of/admin/login, with no error. A recipe can't fix this becauseconfig/routes.yamlbelongs to your app.# config/routes.yaml controllers: resource: path: ../src/Controller/ namespace: App\Controller type: attribute
Then:
# 1. create the database schema (or generate a migration instead — your choice)
bin/console doctrine:schema:update --force
# 2. create an admin user
bin/console kazetenn:admin:create-user you@example.com "a-strong-password"
You can now log in at /admin, create an article, publish it — it's live at /article/{slug},
listed at /article, with a feed at /article/rss.
The bundled Article type above ships as an example (Kazetenn\Articles). To model
your own content, drop a #[ContentType] class anywhere your app autoloads, register
it, and run doctrine:schema:update --force again.
Migrations are your choice. Content types are mapped as standard Doctrine metadata, so you keep the schema in sync however you like —
doctrine:schema:update --forcefor quick iteration, or Doctrine Migrations if you prefer. When you go throughschema:update, a new non-nullable#[Field]on a type that already has rows must carry a SQL default.
Configuration
Every option has a sensible default; override only what you need:
# config/packages/kazetenn.yaml
kazetenn_admin:
path_prefix: /admin # where the admin lives
kazetenn_display:
path_prefix: / # e.g. /blog to namespace the whole public site
rss: true # false → /{type}/rss returns 404
cache:
list_max_age: 60 # seconds — list pages
show_max_age: 3600 # detail pages
rss_max_age: 3600 # feeds
Field types
Each FieldType ties a PHP property to a database column and an admin form widget:
FieldType | PHP type | Database column | Admin widget |
|---|---|---|---|
String | string | string | single-line text |
Text | string | text | textarea |
RichText | string | text | textarea + rich-text editor |
Integer | int | integer | number (integer) |
Float | float | float | number |
Boolean | bool | boolean | checkbox |
DateTime | \DateTimeImmutable | datetime_immutable | date & time picker |
Json | array | json | textarea (JSON) |
Tags | list<string> | custom TagsType — a portable text column, no join table | comma-separated input with autocomplete |
Declare the matching property type yourself and add any Symfony validation constraints
(#[Assert\...]) on it — the admin form enforces them.
How it works
- Content types are plain classes. Nothing is generated on disk — the mapping is built
at runtime by a custom Doctrine
MappingDriver:- the name you pass to
#[ContentType]becomes the table name verbatim — no prefix, no pluralization (#[ContentType('article')]→ tablearticle); - each
#[Field]property becomes a column, snake-cased from the property name (publishedAt→published_at).
- the name you pass to
- The admin is reflective. The edit form is built from your
#[Field]attributes and your standard Symfony validation constraints; nothing to wire per field. - Display is by convention. A type renders through
@KazetennDisplay/content/{type}/{view}.html.twigif you provide it, and a generic fallback otherwise — override a template only when you want to.
The bundles — Core, Articles (example), Admin, Display — are independent: keep
Core plus whichever you need. For now you can disable them in your config/bundles.php
Tests
The suite runs inside the project's Docker stack (make start first). make check runs
everything — PHPUnit, Deptrac for the architecture boundaries, and PHPStan — or run each
on its own:
make test # PHPUnit — all suites
make arch # Deptrac — layer boundaries
make stan # PHPStan
The core, articles, admin and display suites run on in-memory SQLite; the
end-to-end app suite talks to the PostgreSQL database from the dev stack.
License
Apache-2.0. See LICENCE.md and NOTICE.md.