PHPackages                             netbull/core-bundle - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Database &amp; ORM](/categories/database)
4. /
5. netbull/core-bundle

ActiveSymfony-bundle[Database &amp; ORM](/categories/database)

netbull/core-bundle
===================

Symfony utility bundle: entity-reference and phone-number form types, three-query Doctrine paginator, spatial/range/phone DBAL types, JS route export and Twig helpers

v7.0.11(3w ago)016.5k↓31.1%1MITPHPPHP &gt;=8.3CI passing

Since Apr 3Pushed 3w ago1 watchersCompare

[ Source](https://github.com/netbull/CoreBundle)[ Packagist](https://packagist.org/packages/netbull/core-bundle)[ RSS](/packages/netbull-core-bundle/feed)WikiDiscussions master Synced 3d ago

READMEChangelog (10)Dependencies (37)Versions (145)Used By (1)

NetBull CoreBundle
==================

[](#netbull-corebundle)

A Symfony utility bundle bundling the pieces NetBull apps share: entity-reference (AJAX/Select2) and phone-number form types, a three-query Doctrine paginator with sortable-column Twig helpers, custom DBAL types (spatial, integer range, phone number), a route exporter for frontend JavaScript, and assorted Twig filters.

- **PHP:** &gt;= 8.3
- **Symfony:** 7.4 (LTS)

Installation
------------

[](#installation)

```
composer require netbull/core-bundle
```

### Register the bundle

[](#register-the-bundle)

With Symfony Flex this is automatic. Otherwise add it to `config/bundles.php`:

```
return [
    // ...
    NetBull\CoreBundle\NetBullCoreBundle::class => ['all' => true],
];
```

Configuration
-------------

[](#configuration)

Everything is optional — the bundle works with an empty `netbull_core:` config. Defaults shown:

```
# config/packages/netbull_core.yaml
netbull_core:
    # JS routing export (bin/console netbull:core:js-routing)
    js_routes_path: null        # output file, relative to the project dir, e.g. 'assets/js/router.js'
    js_type: 'js'               # 'js' (window.Netbull.Router) or 'es6' (ES module)

    # Defaults for the AjaxType / Select2Type widgets
    form_types:
        ajax:
            minimum_input_length: 1
            page_limit: 10
            allow_clear: false
            delay: 250
            language: 'en'
            cache: true

    # Classes/icons used by the pagination_sortable() Twig function (FontAwesome by default)
    paginator:
        sortable:
            icons:
                none: 'fa fa-sort'
                asc: 'fa fa-sort-up'
                desc: 'fa fa-sort-down'
            active_class: 'text-success'
            not_active_class: 'text-primary'
```

To use the bundle's form widgets, register its form themes:

```
# config/packages/twig.yaml
twig:
    form_themes:
        - '@NetBullCore/Form/forms.html.twig'
        - '@NetBullCore/Form/phone_number.html.twig'   # or phone_number_bootstrap.html.twig
```

The markup is Bootstrap-flavored and uses FontAwesome icons; the select widgets emit [Select2](https://select2.org/) data attributes — the app ships and initializes the JS itself.

Pagination
----------

[](#pagination)

The `Paginator` service runs three queries — a COUNT, an IDs query (LIMIT/OFFSET + ORDER BY on primary keys only), and a data query restricted to those IDs — so large offsets stay fast and joins never break the limit. Page (`page`/`currentPage`), page size (`perPage`/`pageSize`, the literal `all` disables the limit) and sorting (`field` + `direction`) are sniffed from the request automatically.

Have the repository implement `PaginatorRepositoryInterface`:

```
class AccountRepository extends ServiceEntityRepository implements PaginatorRepositoryInterface
{
    public function getPaginationCount(array $params = []): QueryBuilder
    {
        return $this->createQueryBuilder('a')->select('COUNT(a.id)');
    }

    public function getPaginationIds(array $params = []): QueryBuilder
    {
        return $this->createQueryBuilder('a')->select('a.id');
    }

    public function getPaginationQuery(array $params = []): QueryBuilder
    {
        return $this->createQueryBuilder('a')->select('a', 'p')->leftJoin('a.profile', 'p');
    }
}
```

Wire the three builders in the controller (the service is autowirable):

```
public function index(AccountRepository $repo, Paginator $paginator): JsonResponse
{
    $params = []; // your filters

    // default sort — guard it, setSorting() replaces the sorting sniffed from the request
    if (!$paginator->getSorting()) {
        $paginator->setSorting(new Sorting('a.createdAt', Sorting::DIRECTION_DESC));
    }

    $paginator
        ->setCountQuery($repo->getPaginationCount($params))
        ->setIdsQuery($repo->getPaginationIds($params))
        ->setQuery($repo->getPaginationQuery($params));

    return $this->json($paginator->paginateShort());
    // { items: [...], pagination: { currentPage, pageSize, totalItems } }
}
```

`paginate()` returns a richer pagination array (page ranges, route, sorting, ...) for classic server-rendered lists; `setItemNormalizer(fn (array $row) => ...)` maps every item before output. `PaginatorSimple` is a one-query variant for when you already hold the filtered ID list (`setIds([['id' => 1], ...])` + `setQuery(...)`).

> **FIELD() requirement** — the data query re-orders rows with MySQL's `FIELD()`, which Doctrine doesn't know natively. Register it once (the bundle ships `beberlei/doctrineextensions` for this):
>
> ```
> # config/packages/doctrine.yaml
> doctrine:
>     orm:
>         dql:
>             string_functions:
>                 FIELD: DoctrineExtensions\Query\Mysql\Field
> ```

### Sortable column headers in Twig

[](#sortable-column-headers-in-twig)

```
{# `pagination` is the array from paginator.paginate() #}
{{ pagination_sortable(pagination, 'Name', 'a.name') }}
```

Clicking cycles ascending → descending → cleared. A default `Sorting` must be set on the paginator for the links to render. `query_inputs('q')` emits hidden inputs preserving the other query parameters inside a GET filter form.

Form types
----------

[](#form-types)

TypeWhat it does`DynamicType`Entity `` that renders only the selected option(s); the app's JS loads the rest`AjaxType``DynamicType` + remote search: generates an endpoint URL from a route, emits Select2 AJAX data attributes`Select2Type``AjaxType` + full Select2 config (`allow_clear`, `delay`, `language`, `cache`, `tags`)`EntityHiddenType`Entity reference in a hidden input (id ↔ entity via model transformer)`AutoCollectionType`Core `CollectionType` rendered with a `data-prototype` + "Add new" button widget`UnorderedCollectionType`Collection keyed/matched by an identifier property instead of array position`CompoundRangeType`min/max integer pair stored as a `"min-max"` string`MoneyType`Core MoneyType with fixed `1.234,56`-style separators regardless of locale (`localize: true` opts back out)`PhoneNumberType`libphonenumber-backed input — single text or country-select + national number`PointType` / `PointTextType``"lat, lng"` hidden/text input mapped to the `Point` value object```
$builder->add('customer', AjaxType::class, [
    'class' => Customer::class,
    'text_property' => 'name',
    'remote_route' => 'app_customers_search',   // generated with ?perPage=
    'minimum_input_length' => 2,
    'placeholder' => 'Search customers...',
    'multiple' => false,
]);
```

The endpoint receives the Select2 request and should return the stock Select2 shape `{ results: [{ id, text }], pagination: { more } }` (or the app's JS overrides `processResults`). Submitted values are entity primary keys (`primary_key` option, default `id`).

`UnorderedCollectionType` matches submitted rows to existing items by an identifier instead of their index, so reordered/partial submissions behave predictably; duplicate identifiers are rejected with a form error:

```
$builder->add('contacts', UnorderedCollectionType::class, [
    'entry_type' => ContactType::class,
    'property' => 'id',
    'allow_add' => true,
    'allow_delete' => true,
]);
```

```
$builder->add('phone', PhoneNumberType::class, [
    'widget' => PhoneNumberType::WIDGET_COUNTRY_CHOICE,
    'country_choices' => ['BG', 'DE', 'GB'],
    'preferred_country_choices' => ['BG'],
]);
```

Doctrine utilities
------------------

[](#doctrine-utilities)

### DBAL column types

[](#dbal-column-types)

Register the ones you use:

```
# config/packages/doctrine.yaml
doctrine:
    dbal:
        types:
            point: NetBull\CoreBundle\ORM\Types\Point
            range: NetBull\CoreBundle\ORM\Types\Range
            phone_number: NetBull\CoreBundle\ORM\Types\PhoneNumber
            geometry: NetBull\CoreBundle\ORM\Types\Geometry
            linestring: NetBull\CoreBundle\ORM\Types\Linestring
            multilinestring: NetBull\CoreBundle\ORM\Types\MultiLinestring
            multipolygon: NetBull\CoreBundle\ORM\Types\Multipolygon
```

```
use NetBull\CoreBundle\ORM\Objects\Point;

#[ORM\Column(type: 'point', nullable: true)]
private ?Point $coordinates = null;          // new Point($latitude, $longitude)

#[ORM\Column(type: 'phone_number', nullable: true)]
private ?\libphonenumber\PhoneNumber $phone = null;   // stored as E.164, VARCHAR(35)
```

- `point` maps to a MySQL `POINT` column via `ST_PointFromText`/`ST_AsText`.
- `range` stores `NetBull\CoreBundle\ORM\Objects\Range` as a `"min-max"` string — create the column manually (string), schema generation does not emit a type for it.
- The four spatial types (`geometry`, `linestring`, `multilinestring`, `multipolygon`) validate WKT through [geoPHP](https://github.com/itamair/geoPHP) — `composer require itamair/geophp`to use them. All spatial SQL targets MySQL/MariaDB. Note that `geometry` accepts POLYGON/MULTIPOLYGON WKT only, despite the generic name.

### GREATEST() in DQL

[](#greatest-in-dql)

```
doctrine:
    orm:
        dql:
            numeric_functions:
                GREATEST: NetBull\CoreBundle\Query\Mysql\Greatest
```

```
SELECT GREATEST(p.updatedAt, p.createdAt) FROM App\Entity\Page p
```

Phone number validation
-----------------------

[](#phone-number-validation)

```
use NetBull\CoreBundle\Validator\Constraints\PhoneNumber;

#[PhoneNumber(defaultRegion: 'BG', type: PhoneNumber::MOBILE)]
private ?string $phone = null;
```

Accepts strings, stringables or `libphonenumber\PhoneNumber` objects; with the default `defaultRegion` (`ZZ`) the value must be in international format (`+359...`). Type-specific messages out of the box (`mobile`, `fixed_line`, `toll_free`, ...). Pair with the `phone_number` DBAL type and `PhoneNumberType` form type.

In Twig:

```
{{ user.phone|phone_number_format }}   {# INTERNATIONAL by default #}
{% if user.phone is phone_number_of_type(constant('libphonenumber\\PhoneNumberType::MOBILE')) %}...{% endif %}
```

JS routing
----------

[](#js-routing)

Export routes to the frontend without FOSJsRoutingBundle. Only routes marked with the `expose` option are dumped:

```
#[Route('/api/items/{id}', name: 'app_item', options: ['expose' => true])]
```

```
netbull_core:
    js_routes_path: 'assets/js/router.js'
    js_type: 'js'    # or 'es6'
```

```
bin/console netbull:core:js-routing            # writes /assets/js/router.js
bin/console netbull:core:js-routing --target=/tmp/router.js
```

The `js` flavor defines `window.Netbull.Router` with one function per route:

```
const url = window.Netbull.Router.app_item(42); // "/api/items/42"
```

The `es6` flavor default-exports a router module:

```
import Router from './router';

const url = Router.get('app_item', 42); // "/api/items/42"
```

Parameters are substituted positionally as-is (no URL-encoding).

Twig helpers
------------

[](#twig-helpers)

HelperKindExample`pagination_sortable(pagination, label, field)`functionsortable `` link (see Pagination)`query_inputs(currentField)`functionhidden inputs preserving current query params`helperText(text)`functionFontAwesome question-mark tooltip icon`lipsum(length = 30)`functionlorem-ipsum filler text`inflect(count = 0)`filter`{{ 'item'|inflect(items|length) }}` → "item"/"items"`titleize`filter`{{ 'first_name'|titleize }}` → "First Name"`country(locale = '')`filter`{{ 'BG'|country }}` → "Bulgaria"`strip_tags_super`filterextracts the `` text of a full HTML document`phone_number_format(format?)`filterformats a `libphonenumber\PhoneNumber``phone_number_of_type(type)`testchecks the number typeUtilities
---------

[](#utilities)

- `Inflect` — English pluralize/singularize/titleize/underscore/humanize (powers the Twig filters).
- `PrintLabels` — TCPDF subclass that prints text labels onto A4 sticker sheets (built-in `Labels` 5×13 and `OrderLabels` 3×7 grids). Legacy; requires `composer require tecnickcom/tcpdf`.

Development
-----------

[](#development)

```
composer test        # PHPUnit
composer phpstan     # static analysis
composer cs-check    # coding standards (php-cs-fixer, dry-run)
composer check       # all of the above
```

License
-------

[](#license)

[MIT](LICENSE)

###  Health Score

62

—

FairBetter than 99% of packages

Maintenance95

Actively maintained with recent releases

Popularity25

Limited adoption so far

Community13

Small or concentrated contributor base

Maturity96

Battle-tested with a long release history

 Bus Factor1

Top contributor holds 55.6% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~23 days

Recently: every ~41 days

Total

144

Last Release

24d ago

Major Versions

v5.4.15 → v6.1.02024-10-21

v5.4.16 → v6.4.42024-12-30

v5.4.17 → v6.4.52025-01-29

v5.4.18 → 6.x-dev2025-01-29

v5.4.19 → v7.0.02025-05-22

PHP version history (2 changes)v6.4.0PHP 8.\*

v7.0.10PHP &gt;=8.3

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/2797954?v=4)[Aleksandar Dimitrov](/maintainers/netbull)[@netbull](https://github.com/netbull)

---

Top Contributors

[![netbull](https://avatars.githubusercontent.com/u/2797954?v=4)](https://github.com/netbull "netbull (139 commits)")[![SLRBot](https://avatars.githubusercontent.com/u/31204388?v=4)](https://github.com/SLRBot "SLRBot (74 commits)")[![semantic-release-bot](https://avatars.githubusercontent.com/u/32174276?v=4)](https://github.com/semantic-release-bot "semantic-release-bot (37 commits)")

---

Tags

symfonybundlepaginatordoctrineJs RoutingspatialFormsselect2phone-number

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StylePHP CS Fixer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/netbull-core-bundle/health.svg)

```
[![Health](https://phpackages.com/badges/netbull-core-bundle/health.svg)](https://phpackages.com/packages/netbull-core-bundle)
```

###  Alternatives

[easycorp/easyadmin-bundle

Admin generator for Symfony applications

4.3k17.9M388](/packages/easycorp-easyadmin-bundle)[open-dxp/opendxp

Content &amp; Product Management Framework (CMS/PIM)

9421.6k61](/packages/open-dxp-opendxp)[chameleon-system/chameleon-base

The Chameleon System core.

1028.6k5](/packages/chameleon-system-chameleon-base)[sylius/sylius

E-Commerce platform for PHP, based on Symfony framework.

8.5k5.9M738](/packages/sylius-sylius)[contao/core-bundle

Contao Open Source CMS

1231.6M2.8k](/packages/contao-core-bundle)[oro/platform

Business Application Platform (BAP)

645143.5k115](/packages/oro-platform)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
