Skip to content

Cn8001/PointArt

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

37 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

PointArt

Sponsor PointArt

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 @RestController and @GetMapping
  • Dependency injection#[Wired] replaces @Autowired; the container resolves constructor and property dependencies via Reflection
  • ORM#[Entity], #[Column], #[Id] replace JPA annotations; Model gives you find(), findAll(), save(), delete() with no SQL
  • Repository pattern — extend Repository, declare abstract methods like findByNameAndEmail(), 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+


Documentation

For full documentation and guides, visit pointartframework.com.


Examples

Working examples are included in app/UserController, ProductController, their models, repositories, and views cover the full feature set and are a good starting point.


Getting Started

1. Clone and configure

cp .env.example .env

Edit .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=utf8mb4

2. Point your web server at the project root

The included .htaccess rewrites all requests to index.php. For Apache, ensure mod_rewrite is enabled.

3. Clear the route cache after any code changes

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.


Namespaces

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.


Directory Structure

/
├── 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 .htaccess blocks direct access to everything else under app/ — files placed outside app/public/ will return 403.


Controllers

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]);
    }
}

#[Router]

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: ''

#[Route]

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

Method parameters

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

#[RequestParam]

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 types

Return value Response
string Echoed as HTML
array or object JSON-encoded with Content-Type: application/json

Dependency Injection

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
}

#[Wired]

Parameter Type Required Description
required bool No When true (default), the dependency is eagerly resolved. When false, it is skipped if unavailable

#[Service]

Parameter Type Required Description
name string No Logical name for the service. Default: ''

Models

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;
}

#[Entity]

Parameter Type Required Description
tableName string Yes The database table this class maps to

#[Id]

No parameters. Marks the primary key property — must be ?int, initialised to null. Set automatically after save().

#[Column]

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

Static query methods

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

Instance methods

$user = new User();
$user->name  = 'Alice';
$user->email = 'alice@example.com';
$user->save();    // INSERT (id is null) or UPDATE

$user->delete();  // DELETE WHERE id = ?

Repositories

Place repository classes in app/repositories/.

Extend Repository and set $entityClass. Declare the class abstract — a concrete implementation is generated at runtime.

#[Query]

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;
}

Built-in methods

find($id), findAll(), save($entity), delete($entity), deleteById($id)

Dynamic finders

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


Views

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');

Security

CORS

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=86400

When enabled, CORS headers are set on every response. OPTIONS preflight requests are intercepted by the framework and return 204 — no controller is involved.

CSRF

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=false

A POST without a valid token returns 403 Forbidden.


Updater

PointArt includes a built-in self-updater that pulls new framework versions from GitHub Releases — no CLI or SSH required.

Setup

Enable the updater in .env and set a secret:

UPDATER_ENABLED=true
UPDATER_SECRET=your-secret-here

Usage

  1. Visit /pointart/update in your browser
  2. Enter your updater secret
  3. Review the current and latest versions, plus release notes
  4. 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.

Configuration

.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.


Error Handling

// 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).


Configuration

.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

Support

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.


License

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.

About

Pointart: A minimalist, modern approach and flexible PHP micro-framework built for the modern web.

Topics

Resources

License

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors