Skip to content

Using it with a framework

CommonKit has no dependency on any framework — its composer.json requires PHP, intl and mbstring, and nothing else. It was built for the three moments where an application meets its framework's edges, and on Laravel, Joomla! and WordPress the same calls go in the same places: reading the request, where CkRequest fills the bag every CkDictionary reader takes; saving, where CkSentinel::ToNull turns every placeholder — empty text, a zero, 1900-01-01, year one — into an honest NULL on the way to the database; and storing files, where CkFile keeps every file inside a FilePlace the platform already has a folder for. One section per framework: how the library comes in, and where each call goes.

The framework plumbing below lives in your application, extension or plugin; the library only supplies the value logic. The blocks that reach into a framework are fragments — they show where the call goes, not a working file.

Laravel

Getting it in

Composer, which is Laravel's autoloader too: once the package is required, every class is there from the next request on, with nothing to register and no service provider. Today the library travels as source, so it comes in as a path repository on the php/ folder of the repository — one entry in your application's composer.json:

{
    "repositories": [
        { "type": "path", "url": "../CommonKit/php" }
    ]
}

then, from the application's folder:

composer require safecoder/commonkit:@dev

Once it is on Packagist the first block goes away and the second loses its @dev: composer require safecoder/commonkit. Getting started shows the folder layout the path points at.

Reading the request

Laravel has read the request before your code runs, but the raw body stays readable, so CkRequest reads the same in a controller or a form request as in plain PHP: on first use it reads what the current request carries straight from what PHP was handed, never from $_REQUEST, picks the reader by the request's method and content type, and every call hands out a typed value. On GET and HEAD its own part is the raw query string — never $_GET, whose keys PHP has already rewritten; on any other method a JSON body (application/json, or any …+json) or a form body (application/x-www-form-urlencoded), PUT and PATCH included; a multipart/form-data body is the one PHP has already consumed, so on POST it is $_POST, keys as PHP rewrote them. In a controller nothing is set up, and each field comes out through the reader that owns its kind:

// no-check
use Illuminate\Http\Request;
use SafeCoder\CommonKit\CkDictionary;
use SafeCoder\CommonKit\CkEmail;
use SafeCoder\CommonKit\CkRequest;

Route::post('/signup', function (Request $request) {
    $email = CkEmail::Address(CkRequest::String('email'));   // a JSON body or a form, whichever the client sent
    $newsletter = CkRequest::Bool('newsletter');
    $page = CkDictionary::Integer(dictionary: CkRequest::Bag()->Query(), key: 'page', fallback: 1);   // the address, on a POST
    // …
});

A test sets the request as the web server would — the method, the content type, the query string, the fields of a form — and the first call reads it, which is also the clearest way to show what the readers do:

use SafeCoder\CommonKit\CkDictionary;
use SafeCoder\CommonKit\CkRequest;

$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['QUERY_STRING'] = 'page=2&active=yes';
CkRequest::Bag()->Query();                         // -> ['page' => '2', 'active' => 'yes']   (a GET: the raw query string)

$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['CONTENT_TYPE'] = 'multipart/form-data';
$_SERVER['QUERY_STRING'] = 'page=2';
$_POST = ['id' => '7', 'email' => 'ANA@EXAMPLE.COM'];
CkRequest::Bag()->Body();                          // -> ['id' => '7', 'email' => 'ANA@EXAMPLE.COM']   (a POST: the form, its own part)
CkRequest::Number('id')->Integer();                    // -> 7
CkDictionary::Integer(CkRequest::Bag()->Query(), 'page');   // -> 2   (the address, by name)

$_SERVER['CONTENT_TYPE'] = 'text/plain';
CkRequest::Explain();                              // -> 'a text/plain body is not one the reader knows'

The bag is raw on purpose — '2' is text, 'ANA@EXAMPLE.COM' is upper case — because cleaning happens at each read, where the kind of the field is known. Where you want the bag itself, for a validator or a saving hook, CkRequest::Bag()->Body() is it, and CkPayload::FromJson(), with nothing passed, reads the request's body straight into one. Where you would rather go through the request object Laravel built, $request->getContent() is the raw body — the same text CkRequest reads — and FromJson takes it; and $request->all(), the array Laravel decoded from the body or the form, is a bag already, so it feeds CkDictionary as it is:

// no-check
$payload = CkPayload::FromJson($request->getContent());   // the body as received

A body whose root is a JSON array is a batch: $request->getContent() hands it over as received, for FromJsonMaps — and for a webhook signature computed over the bytes as sent, which is the request object's job, never the library's.

A Carbon that CkDate::ToCarbon hands you is the library's own copy of the class, not Laravel's: anything typed DateTimeInterface takes it, and Carbon::instance($moment) makes it Laravel's in one call.

Validating

The validator of Writing a validator — one static method that takes the bag and answers every field's reason at once, empty when the payload is good — goes in a form request's withValidator, where Laravel folds the reasons into the response it already knows how to send: a redirect back with the errors for a form, a 422 with them as JSON for an API client. A controller can call it the same way before the work starts.

// no-check
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
use SafeCoder\CommonKit\CkRequest;

final class SignupRequest extends FormRequest
{
    public function rules(): array
    {
        return [];   // the library judges the values, below
    }

    public function withValidator(Validator $validator): void
    {
        $validator->after(static function (Validator $validator): void {
            foreach (SignupValidator::Validate(CkRequest::Bag()->Body()) as $field => $reason) {
                $validator->errors()->add($field, $reason);
            }
        });
    }
}

The guide builds the class; nothing about it changes here.

Saving honest NULLs

Eloquent's saving event fires once for every insert and update, which makes it the one place to turn placeholders into NULL. ToNull looks at any value and gives one verdict: a placeholder in, null out; a real value in, the same value out, untouched:

use SafeCoder\CommonKit\CkSentinel;

CkSentinel::ToNull('   ');                                 // -> null
CkSentinel::ToNull(0);                                     // -> null
CkSentinel::ToNull(new DateTimeImmutable('1900-01-01'));   // -> null   (a placeholder date)
CkSentinel::ToNull('Ana');                                 // -> 'Ana'

Name the columns the table lets be NULL — keys and NOT NULL columns keep their values:

// no-check
use Illuminate\Database\Eloquent\Model;
use SafeCoder\CommonKit\CkSentinel;

final class Member extends Model
{
    /** the columns the table lets be NULL */
    private const Nullable = ['referrer', 'phone', 'note', 'terminated_at'];

    protected static function booted(): void
    {
        static::saving(static function (Member $member): void {
            foreach (self::Nullable as $column) {
                $member->setAttribute($column, CkSentinel::ToNull($member->getAttribute($column)));
            }
        });
    }
}

The same call fits a custom attribute cast's set(), one column at a time, when you would rather declare it beside the column than list the columns in one place:

// no-check
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use SafeCoder\CommonKit\CkSentinel;

final class NullableCast implements CastsAttributes
{
    public function get(Model $model, string $key, mixed $value, array $attributes): mixed
    {
        return $value;
    }

    public function set(Model $model, string $key, mixed $value, array $attributes): mixed
    {
        return CkSentinel::ToNull($value);   // '' becomes NULL on the way to the row
    }
}

and 'referrer' => NullableCast::class among the model's casts. What ToNull turns into null, and the zeros flag, are on the CkSentinel page.

From then on a query for "missing" asks for NULL and '' both — whereNull('referrer')->orWhere('referrer', '') — because the table can hold rows saved before the hook existed; the query trap is on Common mistakes.

Storing files

Where the files go is one line in your copy of CkSettings.php:

// no-check
public const FilePlatform = FilePlatform::Laravel;

From then on every FilePlace is a folder under storage/app, found through storage_path(): what is served under storage/app/public — images/, media/, videos/, audio/, the folder the storage:link command publishes — and the rest under storage/app itself: documents/, uploads/, temporary/, imports/, exports/, archives/. Nothing else to configure; the folders are made the first time something is written there. An upload comes out of the request and goes through the place's rules — the extension one the place takes and never a blocked one, the size within the limit, the content proving the extension — before a byte is written:

// no-check
use Illuminate\Http\Request;
use SafeCoder\CommonKit\CkFile;
use SafeCoder\CommonKit\Enums\FilePlace;
use SafeCoder\CommonKit\Exceptions\CkFileException;

Route::post('/claims/{claimId}/documents', function (Request $request, int $claimId) {
    $upload = $request->file('scan');
    $bytes = $upload === null ? '' : (string) file_get_contents($upload->getRealPath());
    $path = 'claim-' . $claimId . '/' . CkFile::UniqueName($upload?->getClientOriginalName());

    try {
        CkFile::Write(FilePlace::Documents, $path, $bytes, throwIfInvalid: true);
    } catch (CkFileException $refused) {
        return response()->json(['error' => $refused->Reason], 400);
    }

    return response()->json(['path' => $path]);
});

Serving it back is response()->file() over FullPath, which never loads the file. A place that must live elsewhere — archives on a mounted volume — keeps the platform for the rest and says so in .env: CommonKit__Files__Archives__Root=/mnt/archive. The whole of it, the rules and the walk-through, is on Setting up your file places.

Joomla!

Getting it in

The library ships as an installable folder: grab it from the repository's releases page and install it the way Joomla! installs any extension. From then on the classes autoload, and use SafeCoder\CommonKit\… works in a component, a module or a plugin alike — nothing to require, nothing to register.

Reading the input

The application's input object has read the request. A form arrives under jform, and asked for with the array filter it is the bag as sent, values untouched, which is what the readers want — any other filter would already have changed them:

// no-check
use Joomla\CMS\Factory;
use SafeCoder\CommonKit\CkDictionary;
use SafeCoder\CommonKit\CkEmail;

$input = Factory::getApplication()->getInput();
$payload = $input->get('jform', [], 'array');

$email = CkEmail::Address(CkDictionary::String(
    dictionary: $payload,
    key: 'email'
));
$newsletter = CkDictionary::Bool(
    dictionary: $payload,
    key: 'newsletter'
);
// …

The bag is raw on purpose — the values are text, as the browser sent them — because cleaning happens at each read, where the kind of the field is known.

An API request with a JSON body is read the same on Joomla! as anywhere: CkRequest reads the current request itself on first use, straight from what PHP was handed and never from $_REQUEST, picking the reader by the request's method and content type — a JSON body on any method, a form body on POST, PUT and PATCH, the raw query string on GET — and hands out typed values, the way $input->getInt('id') does, with every value judged by the reader that owns its kind. In a controller nothing is set up; a test sets the request as the web server would, which is also the clearest way to show what it does:

use SafeCoder\CommonKit\CkDictionary;
use SafeCoder\CommonKit\CkRequest;

$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['CONTENT_TYPE'] = 'multipart/form-data';
$_SERVER['QUERY_STRING'] = 'option=com_shop&task=order.save';
$_POST = ['id' => '7', 'email' => 'ANA@EXAMPLE.COM'];

CkRequest::Number('id')->Integer();                        // -> 7   (the form, a POST's own part)
CkDictionary::String(CkRequest::Bag()->Query(), 'task');   // -> 'order.save'   (the address, which every Joomla! request carries)

$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['QUERY_STRING'] = 'page=2&active=yes';
CkRequest::Bag()->Query();                             // -> ['page' => '2', 'active' => 'yes']   (a GET: the raw query string, never $_GET)

Where the bag itself is wanted, CkRequest::Bag()->Body() is it, and CkPayload::FromJson(), with nothing passed, reads the request's body straight into one — or takes $input->json->getRaw(), the body the input object already holds.

Validating

The validator of Writing a validator — one static method that takes the bag and answers every field's reason at once, empty when the payload is good — belongs where the form is judged: a table's check(), or the model before it hands the data on. Each reason goes to the application's message queue, which the form shows on the way back:

// no-check
use Joomla\CMS\Factory;

$app = Factory::getApplication();
$payload = $app->getInput()->get('jform', [], 'array');

$problems = SignupValidator::Validate($payload);

foreach ($problems as $field => $reason) {
    $app->enqueueMessage($field . ': ' . $reason, 'error');
}

if ($problems !== []) {
    // back to the form, the data kept
}

The guide builds the class; nothing about it changes here.

Saving honest NULLs

store() is the seam to the database, so the values go through ToNull just before it. ToNull looks at any value and gives one verdict: a placeholder in, null out; a real value in, the same value out, untouched:

use SafeCoder\CommonKit\CkSentinel;

CkSentinel::ToNull('');                                    // -> null
CkSentinel::ToNull('0.00');                                // -> null   (a zero amount, written as text)
CkSentinel::ToNull(new DateTimeImmutable('1900-01-01'));   // -> null   (a placeholder date)
CkSentinel::ToNull('Ana');                                 // -> 'Ana'

One thing to know about store(): it skips NULL values by default on an update, keeping whatever the row holds, so the override passes true. Name the columns the table lets be NULL — keys and NOT NULL columns keep their values:

// no-check
use Joomla\CMS\Table\Table;
use SafeCoder\CommonKit\CkSentinel;

final class MemberTable extends Table
{
    public function store($updateNulls = true)
    {
        foreach (['referrer', 'phone', 'note', 'terminated_at'] as $column) {
            $this->$column = CkSentinel::ToNull($this->$column);
        }

        return parent::store(true);   // updateNulls: a NULL is written, never skipped
    }
}

What ToNull turns into null, and the zeros flag, are on the CkSentinel page.

From then on a query for "missing" asks for NULL and '' both, because the table can hold rows saved before the override existed; the query trap is on Common mistakes.

Storing files

Where the files go is one line in your copy of CkSettings.php:

// no-check
public const FilePlatform = FilePlatform::Joomla;

From then on every FilePlace is a folder of the site, found through JPATH_ROOT: pictures, video and audio under images/, the media manager's folder — Images and Media are that folder itself, Videos and Audio the subfolders videos/ and audio/ of it, so what an extension stores is what the media manager shows — scratch in tmp/, and documents/, uploads/, imports/, exports/ and archives/ each a folder of its own at the site root, made the first time something is written there. An upload comes out of the input object's files and goes through the place's rules — the extension one the place takes and never a blocked one, the size within the limit, the content proving the extension — before a byte is written:

// no-check
use Joomla\CMS\Factory;
use SafeCoder\CommonKit\CkFile;
use SafeCoder\CommonKit\Enums\FilePlace;
use SafeCoder\CommonKit\Exceptions\CkFileException;

$app = Factory::getApplication();
$upload = $app->getInput()->files->get('jform', [], 'array')['scan'] ?? null;   // ['name' => …, 'tmp_name' => …, …]
$bytes = is_array($upload) ? (string) file_get_contents($upload['tmp_name']) : '';
$path = 'claim-' . $claimId . '/' . CkFile::UniqueName($upload['name'] ?? null);

try {
    CkFile::Write(FilePlace::Documents, $path, $bytes, throwIfInvalid: true);
} catch (CkFileException $refused) {
    $app->enqueueMessage($refused->Reason, 'error');
}

Serving a file back is readfile() over FullPath, with the type MediaType names in the header. A place that must live elsewhere keeps the platform for the rest and names its folder in the variable CommonKit__Files__Archives__Root, or in CkSettings::FileRoot(). The whole of it, the rules and the walk-through, is on Setting up your file places.

WordPress

Getting it in

The library ships as an installable folder: grab it from the repository's releases page, put it where WordPress keeps its plugins and activate it. From then on the classes autoload, and use SafeCoder\CommonKit\… works in your plugin or your theme — nothing to require, nothing to register.

Reading the request

WordPress hands a plugin the superglobals and nothing else — and puts a backslash before every quote in them, which wp_unslash() exists to undo. CkSettings::RequestUnslash, set true in the plugin's copy of the library, says so once: CkRequest — and every CkPayload reader reading PHP's input — then reads the current request straight from what PHP was handed, never from $_REQUEST, strips that one level of backslashes from the query string and the multipart fields — the raw body, which WordPress never touches, is read as it is — and every reader hands out a typed value.

The reader is picked by the request's method and content type: on GET and HEAD the raw query string — never $_GET, whose keys PHP has already rewritten; on any other method a JSON body or a form body, PUT and PATCH included; a multipart/form-data body — a form with a file in it — is the one PHP has already consumed, so on POST it is $_POST. In a handler each field comes out through the reader that owns its kind, and nothing is set up:

// no-check
use SafeCoder\CommonKit\CkEmail;
use SafeCoder\CommonKit\CkRequest;

add_action('admin_post_signup', static function (): void {
    $email = CkEmail::Address(CkRequest::String('email'));   // the form, as the browser sent it
    $newsletter = CkRequest::Bool('newsletter');
    // …
});

The bag is raw on purpose — the values are text, as the browser sent them — because cleaning happens at each read, where the kind of the field is known; CkRequest::Bag()->Body() is that bag, for a validator, and CkPayload::FromJson(), with nothing passed, reads the request's body straight into one.

A REST route's callback is handed a WP_REST_Request, whose get_body() is the raw body for FromJson. A webhook wants the same body twice: once as the bytes that were sent, for the signature, and once as a bag. The bytes are the request object's to hand over — WordPress never touches them, so there is nothing to unslash — and the library never hands bytes out; the readers read the same text:

// no-check
use SafeCoder\CommonKit\CkDictionary;
use SafeCoder\CommonKit\CkRequest;

add_action('rest_api_init', static function (): void {
    register_rest_route('shop/v1', '/webhook', [
        'methods'             => 'POST',
        'permission_callback' => '__return_true',   // the signature is the gate
        'callback'            => static function (WP_REST_Request $request) {
            $sent = CkDictionary::String(CkRequest::Bag()->Headers(), 'x-signature');   // the header, by lowercase name

            if (hash_equals(hash_hmac('sha256', $request->get_body(), WEBHOOK_SECRET), $sent) === false) {
                return new WP_Error('bad_signature', 'bad signature', ['status' => 401]);
            }

            $id = CkRequest::Number('id')->Integer();   // the same body, read once, as a bag
            // …
        },
    ]);
});

Validating

The validator of Writing a validator — one static method that takes the bag and answers every field's reason at once, empty when the payload is good — goes at the top of the handler, before anything is written. For a form submitted by fetch() the array is the whole answer; a classic form redirects back with the problems instead:

// no-check
use SafeCoder\CommonKit\CkRequest;

add_action('admin_post_signup', static function (): void {
    $payload = CkRequest::Bag()->Body();

    $problems = SignupValidator::Validate($payload);

    if ($problems !== []) {
        wp_send_json_error($problems, 400);   // every reason at once, as JSON
    }
    // …
});

The guide builds the class; nothing about it changes here.

Saving honest NULLs

$wpdb->insert() and $wpdb->update() take an array of column to value and write a PHP null as SQL NULL — so the values go through ToNull on the way in. ToNull looks at any value and gives one verdict: a placeholder in, null out; a real value in, the same value out, untouched:

use SafeCoder\CommonKit\CkSentinel;

CkSentinel::ToNull('   ');                                 // -> null
CkSentinel::ToNull(0);                                     // -> null
CkSentinel::ToNull(new DateTimeImmutable('1900-01-01'));   // -> null   (a placeholder date)
CkSentinel::ToNull('Ana');                                 // -> 'Ana'
CkSentinel::ToNull(false);                                 // -> false   (a boolean has no placeholder)

Name the columns the table lets be NULL — keys and NOT NULL columns keep their values:

// no-check
use SafeCoder\CommonKit\CkSentinel;

global $wpdb;

$wpdb->insert(
    $wpdb->prefix . 'members',
    [
        'email'    => $email,
        'referrer' => CkSentinel::ToNull($referrer),   // '' becomes NULL
        'phone'    => CkSentinel::ToNull($phone),
    ]
);

$wpdb->update(
    $wpdb->prefix . 'members',
    ['phone' => CkSentinel::ToNull($phone)],
    ['id' => $id]
);

What ToNull turns into null, and the zeros flag, are on the CkSentinel page.

From then on a query for "missing" asks for NULL and '' both, because the table can hold rows saved before the call was there; the query trap is on Common mistakes.

Storing files

Where the files go is one line in your copy of CkSettings.php:

// no-check
public const FilePlatform = FilePlatform::WordPress;

From then on every FilePlace is a lowercase folder of its own under the uploads folder, found through wp_upload_dir() — uploads/images/, uploads/documents/, uploads/exports/ — beside the media library's own year folders and never inside them, made the first time something is written there. An upload comes out of $_FILES and goes through the place's rules — the extension one the place takes and never a blocked one, the size within the limit, the content proving the extension — before a byte is written:

// no-check
use SafeCoder\CommonKit\CkFile;
use SafeCoder\CommonKit\Enums\FilePlace;
use SafeCoder\CommonKit\Exceptions\CkFileException;

add_action('admin_post_claim_document', static function (): void {
    $upload = $_FILES['scan'] ?? null;   // ['name' => …, 'tmp_name' => …, …]
    $bytes = is_array($upload) ? (string) file_get_contents($upload['tmp_name']) : '';
    $path = 'claim-' . absint($_POST['claim_id'] ?? 0) . '/' . CkFile::UniqueName($upload['name'] ?? null);

    try {
        CkFile::Write(FilePlace::Documents, $path, $bytes, throwIfInvalid: true);
    } catch (CkFileException $refused) {
        wp_send_json_error(['error' => $refused->Reason], 400);
    }

    wp_send_json_success(['path' => $path]);
});

The uploads folder is served by the web server, so a place that must stay private — Documents, Imports — is better given a folder of its own above the document root, in the variable CommonKit__Files__Documents__Root or in CkSettings::FileRoot(), and served through readfile() over FullPath; the platform keeps the rest. The whole of it, the rules and the walk-through, is on Setting up your file places.

Where next

  • Writing a validator — every problem with a payload, reported at once.
  • Setting up your file places — the platform's folders for every kind of file, or one root of your own, and the rules each place keeps.
  • Your first payload — a fifteen-minute tutorial: a messy order payload to clean, typed values, and the whole handler at the end.
  • CkSentinel — what ToNull turns into null, and the zeros flag.