Ship powerful features with the simplicity of plain PHP.
A plain PHP micro-framework modelled after Spring Boot's programming model.
- Attribute-based routing —
#[Router]and#[Route]replace@RestControllerand@GetMapping - Dependency injection —
#[Wired]replaces@Autowired; the container resolves constructor and property dependencies via Reflection - ORM —
#[Entity],#[Column],#[Id]replace JPA annotations;Modelgives youfind(),findAll(),save(),delete()with no SQL - Repository pattern — extend
Repository, declare abstract methods likefindByNameAndEmail(), and the framework generates the implementation at runtime — just like Spring Data JPA - Services —
#[Service]marks a class as a singleton in the container, matching Spring's@Service
Views are plain .php files — no compilation, no build step, deploy by copying files. Runs on any shared host with PHP 8.1+ and Apache mod_rewrite.
Requires PHP 8.1+
For full documentation and guides, visit pointartframework.com.
Working examples are included in app/ — UserController, ProductController, their models, repositories, and views cover the full feature set and are a good starting point.
cp .env.example .envEdit .env with your database settings:
APP_DEBUG=false
DB_DRIVER=mysql
DB_HOST=localhost
DB_PORT=3306
DB_DATABASE=pointart
DB_USERNAME=your_user
DB_PASSWORD=your_password
DB_CHARSET=utf8mb4The included .htaccess rewrites all requests to index.php. For Apache, ensure mod_rewrite is enabled.
PointArt scans app/ on the first request and serializes the route and service registry to cache/registry.ser. Every subsequent request reads from that cache — no scanning, no Reflection.
If you add a new controller, route, or service and it doesn't appear — clear the cache.
ClassLoader::clearCache();Or delete cache/registry.ser manually. The cache will be rebuilt on the next request.
| Namespace | Contains |
|---|---|
PointStart\Core |
App, Container, ClassLoader, RouteHandler, Renderer, Env |
PointStart\ORM |
Model, Repository |
PointStart\Attributes |
Router, Route, Service, Wired, RequestParam, Entity, Column, Id, Query |
App-level classes (UserController, User, UserRepository, etc.) live in the global namespace — no namespace declaration needed in your controllers, models, or repositories.
/
├── index.php # Entry point
├── .htaccess # Rewrites all requests to index.php
├── .env # Your local config (gitignored)
├── .env.example # Config template
├── config.php # Reads from .env, returns config array
│
├── framework/
│ ├── attributes/ # PHP Attributes (Route, Router, Service, Wired, …)
│ ├── core/ # App, Container, ClassLoader, RouteHandler, Renderer
│ └── ORM/ # Model, Repository
│
└── app/
├── components/ # Controllers and Services (auto-scanned)
├── models/ # Model subclasses
├── repositories/ # Repository subclasses
├── views/ # Plain .php view files
└── public/ # Static assets served directly (CSS, JS, images)
├── css/
└── js/
All public files (CSS, JS, images, fonts) must go inside
app/public/. The.htaccessblocks direct access to everything else underapp/— files placed outsideapp/public/will return 403.
Place controllers in app/components/. They are auto-scanned on first request.
Mark a class with #[Router] and its methods with #[Route].
#[Router(name: 'user', path: '/user')]
class UserController {
#[Route('/list', HttpMethod::GET)]
public function index(): string {
$users = User::findAll();
return Renderer::render('user.list', ['users' => $users]);
}
#[Route('/show/{id}', HttpMethod::GET)]
public function show(int $id): string {
$user = User::find($id);
if ($user === null) {
return Renderer::render('user.notfound');
}
return Renderer::render('user.show', ['user' => $user]);
}
#[Route('/create', HttpMethod::POST)]
public function create(
#[RequestParam] string $name,
#[RequestParam] string $email
): string {
$user = new User();
$user->name = $name;
$user->email = $email;
$user->save();
return Renderer::render('user.show', ['user' => $user]);
}
}| Parameter | Type | Required | Description |
|---|---|---|---|
path |
string |
No | URL prefix applied to every route in the class (e.g. '/user'). Default: '' |
name |
string |
No | Logical name for the controller. Default: '' |
| Parameter | Type | Required | Description |
|---|---|---|---|
path |
string |
Yes | Route path, relative to the controller prefix. Supports {param} placeholders |
method |
HttpMethod |
No | HttpMethod::GET or HttpMethod::POST. Default: GET |
csrfExempt |
bool |
No | Skip CSRF validation for this route (e.g. webhooks, public APIs). Default: false |
| Source | How to declare | Example |
|---|---|---|
| URL path segment | Typed parameter matching {name} in the route |
int $id for /show/{id} |
Query string ($_GET) |
Typed parameter with a default value | string $name = '' for ?name=foo |
| POST body / file upload | #[RequestParam] on the parameter |
#[RequestParam] string $email |
No parameters. Tells the framework to inject the value from $_POST or $_FILES for this method parameter. Without it, only path params and $_GET are injected.
| Return value | Response |
|---|---|
string |
Echoed as HTML |
array or object |
JSON-encoded with Content-Type: application/json |
Use #[Wired] on a property to have the container inject it automatically.
#[Router(name: 'user', path: '/user')]
class UserController {
#[Wired]
private UserRepository $userRepository;
// $userRepository is resolved and injected before any method is called
}Mark a class as a singleton with #[Service]:
#[Service('myService')]
class MyService {
// one instance shared across the request
}| Parameter | Type | Required | Description |
|---|---|---|---|
required |
bool |
No | When true (default), the dependency is eagerly resolved. When false, it is skipped if unavailable |
| Parameter | Type | Required | Description |
|---|---|---|---|
name |
string |
No | Logical name for the service. Default: '' |
Place model classes in app/models/.
Extend Model, annotate with #[Entity], mark columns with #[Column] and the primary key with #[Id].
#[Entity('users')]
class User extends Model {
#[Id]
public ?int $id = null;
#[Column('name', 'varchar')]
public string $name;
#[Column('email', 'varchar', nullable: true)]
public ?string $email = null;
}| Parameter | Type | Required | Description |
|---|---|---|---|
tableName |
string |
Yes | The database table this class maps to |
No parameters. Marks the primary key property — must be ?int, initialised to null. Set automatically after save().
| Parameter | Type | Required | Description |
|---|---|---|---|
columnName |
string |
Yes | The database column name |
type |
string |
Yes | Column type hint (e.g. 'varchar', 'int', 'real') |
nullable |
bool |
No | Whether the column accepts NULL. Default: false |
| Method | SQL |
|---|---|
User::find($id) |
SELECT * WHERE pk = ? LIMIT 1 |
User::findAll() |
SELECT * |
User::findBy(['col' => $val], $order, $limit) |
SELECT * WHERE col = ? [ORDER/LIMIT] |
User::findOne(['col' => $val]) |
SELECT * WHERE col = ? LIMIT 1 |
$user = new User();
$user->name = 'Alice';
$user->email = 'alice@example.com';
$user->save(); // INSERT (id is null) or UPDATE
$user->delete(); // DELETE WHERE id = ?
Place repository classes in app/repositories/.
Extend Repository and set $entityClass. Declare the class abstract — a concrete implementation is generated at runtime.
| Parameter | Type | Required | Description |
|---|---|---|---|
queryString |
string |
Yes | Raw SQL to execute. Use ? for positional parameters, bound in method signature order |
Return type drives the result shape: array → mapped entity list, int → scalar fetch, void → execute only.
abstract class UserRepository extends Repository {
protected string $entityClass = User::class;
// Custom SQL via #[Query]
#[Query("SELECT * FROM users WHERE name = ? AND email = ?")]
abstract public function findByNameAndEmailRaw(string $name, string $email): array;
#[Query("SELECT COUNT(*) FROM users")]
abstract public function countAll(): int;
// Dynamic finder — no body needed
abstract public function findByName(string $name): array;
}find($id), findAll(), save($entity), delete($entity), deleteById($id)
Method names encode the query — no implementation required:
| Method | SQL |
|---|---|
findByName($n) |
WHERE name = ? |
findByNameAndEmail($n, $e) |
WHERE name = ? AND email = ? |
findByAgeGreaterThan($age) |
WHERE age > ? |
findByNameOrderByEmail($n) |
WHERE name = ? ORDER BY email |
findOneByEmail($e) |
WHERE email = ? LIMIT 1 |
countByStatus($s) |
SELECT COUNT(*) WHERE status = ? |
existsByEmail($e) |
returns bool |
deleteByStatus($s) |
DELETE WHERE status = ? |
Supported operators (suffix on each field segment): GreaterThan, LessThan, GreaterThanEqual, LessThanEqual, Not, Like, IsNull, IsNotNull
Place view files in app/views/. They are plain .php files — no template engine, no build step.
Renderer::render(string $view, array $data = [])| Parameter | Description |
|---|---|
$view |
View name — maps to app/views/<name>.php. Use dot notation for subdirectories (e.g. 'user.list' → app/views/user.list.php) |
$data |
Associative array of variables to pass. Each key becomes a local variable inside the view |
Every key in $data is extracted into the view scope before the file is rendered:
// Controller
return Renderer::render('user.list', [
'users' => $users, // available as $users in the view
'title' => 'All Users', // available as $title in the view
]);<!-- app/views/user.list.php -->
<h1><?= htmlspecialchars($title) ?></h1>
<?php foreach ($users as $user): ?>
<p><?= htmlspecialchars($user->name) ?> — <?= htmlspecialchars($user->email) ?></p>
<?php endforeach; ?>Rendering a view with no data (e.g. a static error page) — omit the second argument:
return Renderer::render('user.notfound');CORS headers are disabled by default (opt-in for backend development). Configure entirely via .env:
CORS_ENABLED=true
CORS_ALLOWED_ORIGINS=*
CORS_ALLOWED_METHODS=GET,POST,OPTIONS
CORS_ALLOWED_HEADERS=Content-Type,Authorization,X-Requested-With
CORS_ALLOW_CREDENTIALS=false
CORS_MAX_AGE=86400When enabled, CORS headers are set on every response. OPTIONS preflight requests are intercepted by the framework and return 204 — no controller is involved.
CSRF protection is enabled by default for all POST requests made from HTML forms. JSON API requests (Content-Type: application/json) bypass the check automatically.
In forms — use csrf_field() to output a hidden token input:
<form method="POST" action="/user/create">
<?= csrf_field() ?>
...
</form>Or retrieve the raw token with csrf_token() for AJAX requests:
fetch('/api/data', {
headers: { 'X-CSRF-Token': '<?= csrf_token() ?>' }
});Exempting a route — set csrfExempt: true on the #[Route] attribute:
#[Route('/webhook', HttpMethod::POST, csrfExempt: true)]
public function webhook(): array { ... }CSRF can be disabled globally via .env:
CSRF_ENABLED=falseA POST without a valid token returns 403 Forbidden.
PointArt includes a built-in self-updater that pulls new framework versions from GitHub Releases — no CLI or SSH required.
Enable the updater in .env and set a secret:
UPDATER_ENABLED=true
UPDATER_SECRET=your-secret-here- Visit
/pointart/updatein your browser - Enter your updater secret
- Review the current and latest versions, plus release notes
- Click Update Now to apply the update
The updater will:
- Back up all overwritten files to
cache/update-backup-{version}/ - Replace framework files (
framework/,index.php,.htaccess,config.php, etc.) - Clear the route cache automatically
Never touched: app/, .env, *.sqlite — your code, config, and data are safe.
.env key |
Default | Description |
|---|---|---|
UPDATER_ENABLED |
false |
Enable the updater route |
UPDATER_SECRET |
— | Secret required to access the updater |
Tip: Disable the updater after updating (
UPDATER_ENABLED=false) to reduce your attack surface.
// In a controller — render a clean error page and stop
httpError(403, 'You do not have permission.');
return '';Convenience wrappers: return404(), return401(), return403(), return405()
Unmatched routes return a 404 automatically. Uncaught exceptions return a 500 (or a full stack trace when APP_DEBUG=true).
.env key |
Default | Description |
|---|---|---|
APP_DEBUG |
false |
Show stack traces on error |
DB_DRIVER |
mysql |
mysql, pgsql, or sqlite |
DB_HOST |
localhost |
Database host |
DB_PORT |
3306 |
Database port (5432 for pgsql) |
DB_DATABASE |
pointart |
Database name |
DB_USERNAME |
— | Database user |
DB_PASSWORD |
— | Database password |
DB_CHARSET |
utf8mb4 |
Charset (MySQL only) |
DB_PATH |
— | Path to SQLite file (SQLite only) |
CORS_ENABLED |
false |
Enable CORS headers |
CORS_ALLOWED_ORIGINS |
* |
Comma-separated list of allowed origins, or * |
CORS_ALLOWED_METHODS |
GET,POST,OPTIONS |
Comma-separated allowed methods |
CORS_ALLOWED_HEADERS |
Content-Type,Authorization,X-Requested-With |
Comma-separated allowed headers |
CORS_ALLOW_CREDENTIALS |
false |
Allow credentials (cookies, auth headers) |
CORS_MAX_AGE |
86400 |
Preflight cache duration in seconds |
CSRF_ENABLED |
true |
Enable CSRF token validation for POST form requests |
UPDATER_ENABLED |
false |
Enable the built-in framework updater route |
UPDATER_SECRET |
— | Secret key required to access the updater |
PointArt is open source and self-funded. If it saves you time or shapes how you think about PHP, consider sponsoring its development on GitHub.
Sponsorship pays for documentation, examples, and the time it takes to ship new features.
PointArt is licensed under the Mozilla Public License 2.0.
You can use, modify, and distribute this software freely. If you modify any MPL-licensed source files, you must make those modifications available under the MPL 2.0. You are not required to open-source code in separate files that merely use this framework.