Development
1. Want to see it live?
You just want to run ProjectForge? Then take the ready-to-use jar or Docker image described in Installation. The steps below are for developers who want to build and change it.
What you need:
-
a JDK 17 or newer, JDK 21 recommended (the code is compiled for Java 17, see Technologies),
-
git,
-
Node.js with npm, only for the Next.js dev server (
bin/pfDev.sh dev); the Gradle build downloads its own Node.js.
Build and run it:
-
Clone the repository:
git clone github.com/micromata/projectforge.gitandcd projectforge. -
Build it without tests:
./gradlew build -x test(gradlew.baton Windows). There is no Maven build. -
Start it, either
-
as the main instance:
bin/pfDev.sh run(Spring Boot on localhost:8080). The first start runs the setup (setup wizard, then the setup page in the browser) as described in Installation, or -
as an independent test instance with test data:
bin/pfDev.sh setup 1(once), thenbin/pfDev.sh run 1(Spring Boot on localhost:8081). The first start fills an own HSQLDB with test data and prints the admin login (see Several instances side by side (bin/pfDev.sh)).
-
-
Open localhost:8080 (or :8081). The pages are served by Spring Boot from the static export of the Next.js frontend under
/next. -
For working on the frontend, start the Next.js dev server with hot reload:
bin/pfDev.sh dev(orbin/pfDev.sh dev 1) and open localhost:3000/next (or :3001/next).
Instead of bin/pfDev.sh run you may also use ./gradlew bootRun or run
java -jar projectforge-application/build/libs/projectforge-application-<version>.jar.
2. Architecture
2.1. Overview
The main functionality of ProjectForge is to display and search data lists and to modify data (CRUD - create, read, update and delete). The goal of the architecture is not to write thousands of lines of code for the same base functionality again and again: an entity declares its fields once, and access checks, full text search, history, REST endpoints, list and edit pages build on that.
ProjectForge is one Spring Boot application (projectforge-application), built with Gradle. The main modules:
| Module | Content |
|---|---|
|
Shared utilities and constants (e. g. the REST paths). |
|
Entities ( |
|
The REST layer under |
|
The frontend: Next.js 16, React 19, TypeScript, TanStack Table/Query/Form, Tailwind CSS 4. Built as a static
export and served by Spring Boot under |
|
The legacy React app under |
|
The JCR document store (Apache Jackrabbit Oak) of existing installations, CardDAV for addresses, user and group
sync with an identity provider (Keycloak, authentik). Attachments are stored by the file store configured with
|
|
The built-in plugins, see Writing own plugins. |
The Wicket UI was removed with ProjectForge 9.0.0. Old Wicket links (/wa/…) are redirected to the
corresponding Next.js pages.
2.2. The layers
2.2.1. Business layer (projectforge-business)
The business layer handles all entities including the access checks for CRUD operations as well as user services (storage of preferences etc.).
2.2.2. REST layer (projectforge-rest)
The REST layer provides the CRUD services of all entities and further services for the frontend. Every call is checked against the access rights of the logged-in user by the business layer.
There are two kinds of entity REST classes:
-
*EntityRest(base classesAbstractEntityRest,AbstractDTOEntityRest): layout-free. It serves data, filter metadata, favorites, history and the write operations; the page itself is hand-built inprojectforge-next. This is the pattern for every new or migrated page, e. g.BookEntityRest. -
*PagesRest(base classesAbstractPagesRest,AbstractDTOPagesRest): additionally generates the page layout on the server (UILayoutJSON), which only the legacy React app displays. These pages are migrated step by step to the*EntityRestpattern.
2.2.3. Frontend (projectforge-next)
The Next.js app (App Router) lives in projectforge-next/; its conventions are described in
projectforge-next/CLAUDE.md and projectforge-next/docs/. In short:
-
Routes in
app/(authenticated)/<entity>/, the domain code incomponents/features/<entity>/, shared components incomponents/shared/andcomponents/data-table/. -
All backend calls go through
lib/rs/and TanStack Query. -
An ordinary list and edit page is declared, not programmed: one
PageDefper entity (definePage()), rendered by the genericEntityListPageandEntityEditPage. -
Field types, maximum lengths, mandatory fields and enum values are not maintained twice: they are generated from the entity’s
@PropertyInfoand JPA annotations intolib/metadata/<entity>.generated.ts. -
The menu and the redirects are decided by the backend (
MenuItemDefId,NextMigration), so a page can be switched toprojectforge-nextindependently of all others.
In development the dev server (bin/pfDev.sh dev, port 3000) proxies /rs/ and /rsPublic/ to Spring
Boot (port 8080, or PF_BACKEND_URL). In production there is only Spring Boot, serving the static export.
2.3. Generated files and i18n
Some files are generated and must never be edited by hand:
-
projectforge-next/lib/metadata/*.generated.ts(field metadata of all entities), -
projectforge-next/messages/generated.*.json(translations for the frontend), -
projectforge-application/src/main/resources/i18nKeys.json(usage of the i18n keys), -
the changelog pages of this website and of the app (from
changelog/changelog{,.de}.json).
All of them are produced by bin/pfDev.sh gen (the Gradle task
:projectforge-application:developmentMainForRelease). Tests fail if a generated file differs from what the
generator produces, so run it after changing an entity or a text.
The source of truth of all texts is projectforge-business/src/main/resources/I18nResources.properties
(English) and I18nResources_de.properties (German); plugins have their own bundles. New texts go there, not
into messages/*.json. Keys named literally in the frontend sources or referenced by the entity metadata are
exported automatically; keys built at runtime need a prefix entry in PREFIXES of
GenerateNextI18nMessagesMain. The hand-written messages/de.json and messages/en.json only hold the few
frontend texts without a backend counterpart.
3. Backend
Java is the language used since the very beginning of ProjectForge in 2001. Since ProjectForge 7 Kotlin is used as well, and new code is written in Kotlin.
3.1. Why Kotlin in addition to Java?
-
Smarter and less code, especially for entities and data transfer objects.
-
Smart operations on collections (map, find, sort etc.).
-
Immutable objects and values where possible.
-
Null safety.
-
Full interoperability with the existing Java code.
Java and Kotlin sources are both compiled for Java 17 (jvmTarget = 17, see
buildSrc/src/main/kotlin/buildlogic.pf-module-conventions.gradle.kts); the application runs on Java 17 or
newer. Micromata runs ProjectForge in production on Java 21, the Docker image is based on Eclipse Temurin 21.
3.2. Entities (BaseDO)
Entities are the objects of ProjectForge stored in the database. By default every modification of an entity is recorded in a history with the information about the modification (user, timestamp, old and new value).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Entity
@Indexed
@Table(name = "T_BOOK", ...)
open class BookDO : DefaultBaseDO(), DisplayNameCapable, AttachmentsInfo {
@PropertyInfo(i18nKey = "book.title", required = true)
@FullTextField
@get:Column(length = 255)
open var title: String? = null
@PropertyInfo(i18nKey = "book.authors")
@FullTextField
@get:Column(length = 1000)
open var authors: String? = null
...
}
|
Entity classes and all their properties must be declared |
|
The |
|
Hibernate Search annotations: the field is indexed and available for the full text search and for field-specific search values. |
|
JPA annotations. Their information (e. g. the length) is available as metadata
and ends up in the generated field metadata of the frontend, e. g. as |
Property type |
The property type is also part of the metadata, so the frontend picks the matching input (text, date picker, user selector, drop down for enums etc.). |
Enumerations implementing I18nEnum (e. g. BookStatus) provide an i18n key per value, so they are
shown as drop down fields with translated labels.
Database changes are made by Flyway migrations, see Flyway migrations.
3.3. Data access objects (BaseDao)
The BaseDao classes provide all CRUD operations for the entities and handle the access rights: no user is
able to select or modify entities without the required rights.
An entity’s DAO (e. g. BookDao) extends BaseDao and defines the access rights and special functionality.
The base functionality including access checks, history and search is inherited.
4. REST API
All CRUD operations and much more are available through the REST API under /rs, which is used by the
frontend. The access rights of the logged-in user are checked for every call; writing calls need the CSRF
token of the session (header X-PF-CSRF-Token, provided by GET /rs/userStatus).
4.1. Entity endpoints
Every *EntityRest inherits the following endpoints from AbstractEntityRest (example: books under
/rs/book):
| Call | Description |
|---|---|
|
Everything a list page needs beside its rows: the filter fields of the entity, the current filter of the user and the saved filter favorites. |
|
The result set for the posted filter ( |
|
One page of the result set, for lists paged on the server. |
|
The entity with the given id. |
|
The preset of a new entity (default values). |
|
Inserts or updates the posted entity. Validation errors are returned with HTTP status 406. |
|
Marks a historizable entity as deleted, restores it, or deletes a non-historizable entity. |
|
Deletes the entity irrevocably including its history (e. g. for privacy protection), if the DAO supports it. |
|
The history of changes of the given entity. |
|
Autocompletion of values already used for a property (e. g. locations of time sheets). |
|
Full text search for entities matching the search string. |
|
Creating, selecting, renaming, updating, deleting and resetting filter favorites. |
|
Rebuilds the search index of the entity. |
Further services of an entity (e. g. lending out a book) are separate REST classes, such as
BookServicesRest.
4.2. Magic filter
Lists are queried with a MagicFilter: a full text search string plus optional field-specific entries.
1
2
3
4
5
6
7
8
{
"searchString": "kotlin",
"entries": [
{ "field": "title", "value": { "value": "java" } },
{ "field": "yearOfPublishing", "value": { "from": "2010" } },
{ "field": "status", "value": { "values": ["PRESENT", "MISSED"] } }
]
}
|
Full text search over all indexed fields. |
|
Search value for the given field. |
|
Range search (both bounds are optional). |
|
Entries whose field has one of the given values. |
The filter last used is stored per user and list; named filters can be saved as favorites.
4.3. Entities vs. DTOs
For simple entities the entity itself may be used by the REST layer. Usually a DTO (data transfer object)
is used, e. g. if the object embeds other entities (users, tasks etc.) or needs special handling
(attachments, images). The base class BaseDTO copies the properties between DTO and entity; the
*EntityRest implements transformForDB and transformFromDB. Examples:
Book.kt,
User.kt.
5. Adding a list and edit page
The books are the reference implementation of a hand-built page. What it takes:
| File | Purpose |
|---|---|
The entity, including |
|
Access rights and special functionality. |
|
The DTO. |
|
The REST endpoints (extends |
|
Flyway migration |
The table of a new entity, see Flyway migrations. |
|
The labels and texts. |
|
Generated field metadata, created by |
|
The page declaration ( |
|
The Zod schema and form values of the edit form, custom sections. |
|
The routes of the list and the edit page ( |
|
The menu entry and the routes of the page. |
Steps in short:
-
Write the entity, the DAO, the DTO and the
*EntityRest, and add the Flyway migration. -
Add the texts to
I18nResources.propertiesandI18nResources_de.properties. -
Run
bin/pfDev.sh gento generate the field metadata and the translations of the frontend. -
Declare the page in
components/features/<entity>/<entity>.page.tsxand add the two route files. -
Register the menu entry and the routes in the backend.
-
Check the frontend with
bin/pfDev.sh check(TypeScript, ESLint, Prettier) and add tests (see Tests).
The declaration format is described in projectforge-next/docs/page-declarations.md. Pages that don’t fit
the declaration use the hooks useEntityListPage and useEntityEditForm directly or are hand-built
completely.
6. Writing own plugins
The built-in plugins live in plugins/ as Gradle modules (e. g. org.projectforge.plugins.skillmatrix or
org.projectforge.plugins.licensemanagement) and are included in the application. A plugin consists of:
-
a class extending
AbstractPlugin, registered insrc/main/resources/META-INF/services/org.projectforge.plugins.core.AbstractPlugin(JavaServiceLoader), which registers DAOs, menu entries, access rights and i18n bundles ininitialize(), -
its entities, DAOs and REST classes, built like the core entities above,
-
its own i18n bundles (e. g.
SkillMatrixI18nResources.propertieswith_deand_envariants), -
its frontend pages in
projectforge-next(components/features/<plugin>/and the routes inapp/(authenticated)/), if it has been migrated.
A new plugin module has to be added to settings.gradle.kts and as a dependency to
projectforge-application/build.gradle.kts. Administrators activate plugins in ProjectForge under
Administration → Plugins (see Installation); projectforge.plugins.active=all
activates all plugins, e. g. on test instances.
7. Tests
-
Backend: JUnit Jupiter (JUnit 6), Mockito and mockito-kotlin. The tests run on HSQLDB, e. g.
./gradlew test --tests "org.projectforge.business.book.*"or./gradlew :projectforge-business:test. -
Frontend unit tests: Vitest,
npm testinprojectforge-next/. -
End-to-end tests: Playwright, in
projectforge-next/e2e/.bin/pfDev.sh e2eruns them against Spring Boot; it refreshes the static export first (:projectforge-application:processResources, a no-op if current).bin/pfDev.sh e2e --devruns them against the dev server,bin/pfDev.sh e2e:uiin Playwright’s UI mode. A running instance in development mode (projectforge.development.mode=true) creates its test accounts itself and writes their credentials intotestAccounts.txtof its home directory. Details are inprojectforge-next/CLAUDE.md.
|
Gradle runs of the same checkout share their build directory: a build rewrites the jar of a running
|
8. Release workflow
8.1. Flyway migrations
Database changes are Flyway migrations in
projectforge-business/src/main/resources/flyway/migrate/common (or under the same version in both
postgresql and hsqldb, if the SQL differs). They are versioned by the release that ships them, always with
four segments: V<release>.<n>RELEASE-<Name>.sql, e. g. V9.0.4.1RELEASE-Script_page_targets.sql. The
release is the version in gradle.properties (without -SNAPSHOT) at merge time.
A migration must never be lower than one already shipped: if a branch misses its planned release, its scripts are renamed to the next release before merging.
8.2. Changelog
The changelog is maintained in changelog/changelog.json (English) and changelog/changelog.de.json (German).
A change in a branch doesn’t touch these files but gets its own file
changelog/unreleased/yyyyMMdd-<slug>.json with English and German text (see changelog/unreleased/README.md).
bin/pfDev.sh release moves them into the release entry; bin/pfDev.sh gen generates the changelog pages of
the website and of the app.
9. Misc
9.1. Code style
Most of the existing code follows the IntelliJ code style in misc/IntelliJ/CodeStyle.xml (indent 2,
continuation indent 4); newer Kotlin code uses 4-space indentation. All source files carry the ProjectForge
license header. The frontend is formatted with Prettier and checked with ESLint (bin/pfDev.sh check).
9.2. Working with different databases for testing
A psql client against a PostgreSQL container named projectforge-postgres (the client image only needs to
be a recent PostgreSQL version, it doesn’t have to match the server’s):
docker run -e PGPASSWORD=$PGPASSWORD -it --rm --link projectforge-postgres:postgres postgres:16 psql -h postgres -U projectforge
create user pf2 password 'secret';
CREATE DATABASE pf2;
GRANT ALL PRIVILEGES ON DATABASE pf2 TO pf2;
For a quick test system without PostgreSQL, use a slot instance with HSQLDB and test data (see below).
9.3. Several instances side by side (bin/pfDev.sh)
bin/pfDev.sh builds and starts the backend, the Next.js dev server and the e2e tests from any working
directory (bin/pfDev.sh help lists all commands). Further commands: gen (generated files, see
Generated files and i18n), check (TypeScript, ESLint, Prettier of the frontend), build (next build),
e2e:ui (e2e tests in Playwright’s UI mode), ps (list the running servers of all slots), stop [slot],
changelog-fold [id] (moves changelog/unreleased/ into a release entry, done by release itself) and
release/publish (release workflow).
A slot number (1–9) as the first argument after the command runs a complete, independent stack beside the main instance, e.g. one per git worktree or branch:
| Slot | Spring | next dev |
Home dir | Database | Session cookie |
|---|---|---|---|---|---|
none (main instance) |
8080 |
3000 |
|
as configured there |
|
n |
8080+n |
3000+n |
|
own HSQLDB with test data |
|
Main instance, unchanged:
bin/pfDev.sh run # build, then Spring on :8080
bin/pfDev.sh dev # next dev on :3000, proxying to :8080
bin/pfDev.sh dev -p 3005 # next dev on :3005, proxying to :8080
bin/pfDev.sh e2e # e2e tests against :8080 (--dev: against the dev server on :3000)
A slot instance, e.g. slot 1:
bin/pfDev.sh setup 1 # once: creates ~/ProjectForge-1 (optional 2nd arg: admin password)
bin/pfDev.sh run 1 # Spring on :8081; the first start fills the database with test data
# and prints the admin login
bin/pfDev.sh dev 1 # next dev on :3001, proxying to :8081
bin/pfDev.sh e2e 1 login # e2e tests against :8081 (--dev: against :3001)
bin/pfDev.sh reset 1 # deletes ~/ProjectForge-1 (asks first), then `setup 1` again
Notes:
-
setupneeds theprojectforge.propertiesof the main home dir (~/ProjectForge). It copiesprojectforge.properties,config.xml(if not yet migrated),resourcesandstylesfrom there and appends a block that switches to an own HSQLDB in~/ProjectForge-n/databaseand turns off sending mails and the gateway push. Database, search index and JCR of the main instance are not copied. -
Port, domain, base dir and cookie name are passed by
pfDev.shas system properties, so the main instance’s settings stay untouched. A slot start doesn’t change the remembered default home dir either. -
The cookie name differs per slot because browsers keep cookies per host, not per port: with the same name, a login on :8081 would end the session on :8080.
-
The test accounts (
testAccounts.txt, seeprojectforge-next/CLAUDE.md) are written into the slot’s home dir;pfDev.sh e2e nreads them from there. -
A slot runs on HSQLDB with test data. Bugs that need real data or PostgreSQL stay with the main instance.
-
Gradle rebuilds the jar of its own checkout, which breaks a
bootRunrunning from that checkout — one slot per worktree avoids that.
Without pfDev.sh, npm run dev (in projectforge-next/) takes the port via -p and the backend via
PF_BACKEND_URL (default localhost:8080, the main instance):
npm run dev -- -p 3005 # :3005 against the main instance
PF_BACKEND_URL=http://localhost:8081 npm run dev -- -p 3001 # :3001 against slot 1
9.4. Knowledge graph (graphify)
Optional developer tooling: graphify builds a local knowledge graph of the code base in graphify-out/,
which AI coding assistants query instead of grepping the whole tree. Both the tool and the graph are
local and untracked — without them the repository behaves exactly as before.
Install it per checkout into a project-local virtual environment (never globally):
python3 -m venv venv
./venv/bin/pip install -q graphifyy
./venv/bin/graphify update .
Note the names: the PyPI package is graphifyy, the command line tool is graphify. Building the graph
with graphify update . is purely AST based — no LLM, no API key, no cost. If you prefer uv or pipx,
those work as well, but plain venv plus pip is the documented default.
The virtual environment must be created inside each checkout: venv/bin/graphify contains an absolute
shebang path, so a venv from another ProjectForge working copy cannot be reused, and every checkout needs
its own graph anyway.
Everyday use:
./venv/bin/graphify query "How does the order book edit page load its data?"
./venv/bin/graphify explain "AbstractEntityRest"
./venv/bin/graphify path "AbstractEntityRest" "UILayout"
./venv/bin/graphify update . # after code changes
Optionally install a post-commit hook that re-extracts changed code files and rebuilds the graph after every commit. It appends to an existing post-commit hook instead of replacing it:
./venv/bin/graphify hook install
graphify-out, venv and .venv are already covered by .gitignore, and the root CLAUDE.md already
contains the matching ## graphify section, so graphify claude install is not needed.