elvandar/kazetenn

Content management framework for Symfony

Maintainers

Package info

gitlab.com/Elvandar/kazetenn

Issues

pkg:composer/elvandar/kazetenn

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

0.1.1 2026-07-27 11:37 UTC

This package is auto-updated.

Last update: 2026-07-27 09:46:29 UTC


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 for RichText, a tag input for Tags, 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:

InheritedTypeNotes
idstringUUIDv7, generated when the object is created — no setter
titlestring
slugstringthe URL segment: /{type}/{slug}. editable, auto-filled by the admin form
statusContentStatusDraft (default) or Published — only Published is ever public
createdAtDateTimeImmutablestamped at creation
updatedAtDateTimeImmutablerefreshed automatically on every save
publishedAt?DateTimeImmutablenull 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 client Content-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.yaml uses 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 /login instead of /admin/login, with no error. A recipe can't fix this because config/routes.yaml belongs 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 --force for quick iteration, or Doctrine Migrations if you prefer. When you go through schema: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:

FieldTypePHP typeDatabase columnAdmin widget
Stringstringstringsingle-line text
Textstringtexttextarea
RichTextstringtexttextarea + rich-text editor
Integerintintegernumber (integer)
Floatfloatfloatnumber
Booleanboolbooleancheckbox
DateTime\DateTimeImmutabledatetime_immutabledate & time picker
Jsonarrayjsontextarea (JSON)
Tagslist<string>custom TagsType — a portable text column, no join tablecomma-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')] → table article);
    • each #[Field] property becomes a column, snake-cased from the property name (publishedAtpublished_at).
  • 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.twig if 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.