justinholtweb/craft-present

Product bundles and build-your-own-box for Craft Commerce — composite pricing, stock rollup across components, and partial fulfilment.

Maintainers

Package info

github.com/justinholtweb/craft-present

Type:craft-plugin

pkg:composer/justinholtweb/craft-present

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-08-27 21:19 UTC

This package is auto-updated.

Last update: 2026-08-29 14:04:06 UTC


README

Product bundles and build-your-own-box for Craft Commerce 5.

A variant is one thing with one SKU and one stock figure. A bundle is several things that are picked, stocked and shipped separately, and sold as one. Present is built around that difference rather than around it.

What makes it different

Every other bundle plugin for Craft sells a bundle as one line item. That is a reasonable place to start and a dead end: Commerce commits stock per purchasable and records fulfilment per line item, so a single-line bundle can never decrement the right stock, can never be partly shipped, and can never be taxed correctly when its contents are taxed at different rates.

Present puts a container line item plus one real line item per component in the cart — the same shape WooCommerce Product Bundles uses, and the reason it can do things variants cannot:

A variant A single-line bundle Present
Stock comes off the right SKU
Sells out when its contents do n/a
Can be partly shipped
Mixed tax rates inside one box
Shopper picks the contents sometimes

Requirements

Craft CMS 5.3+, Craft Commerce 5.0+, PHP 8.2+.

Installation

composer require justinholtweb/craft-present
php craft plugin/install present

Editions

Lite is free and is a complete bundle plugin: bundle types and bundle elements, fixed slots with a list of choices, both pricing modes, slot and option discounts, the stock rollup, and the Twig API.

Pro ($99, $79/year renewal) adds the things that need a shopper in the loop or a warehouse at the other end:

  • Build-your-own-box — quantity ranges per slot, bundle-wide minimum and maximum item counts, optional slots, "no repeats" pools
  • Query-driven pools — a slot filled from a product type or a relation rather than a list
  • Allocation choice — put a fixed price on the bundle line instead of spreading it
  • Edit in cart — let a shopper reconfigure a box they have already added
  • Partial fulfilment — the rollup, the order-screen panel, and the blocker report
  • The JSON API and the drop-in box builder
  • The CP configurator

Pro settings on a Lite install are ignored, not obeyed. They stay in the database untouched, the control panel says which ones are being ignored, and upgrading restores them. A store that lapses does not keep selling boxes it can no longer validate.

Quick start

Make a bundle type under Present → Bundle Types, then a bundle under Present → Bundles. A bundle needs at least one slot; each slot is a thing that goes in the box and a list of what may go there.

The simplest possible template

{% set bundle = craft.present.bundle('GIFTBOX') %}

<h1>{{ bundle.title }}</h1>

{{ craft.present.builder(bundle) }}

That renders the box builder: pickers for every slot, live pricing, live stock, min/max enforced, and an add-to-cart button. It has no dependencies and inherits nothing from the control panel.

Building the form yourself

{% set bundle = craft.present.bundle('GIFTBOX') %}
{% set config = craft.present.defaultConfiguration(bundle) %}

<form method="post">
  {{ csrfInput() }}
  {{ actionInput('present/cart/add') }}
  <input type="hidden" name="bundleId" value="{{ bundle.id }}">

  {% for slot in bundle.slots %}
    <fieldset>
      <legend>{{ slot.name }}</legend>

      {% for option in slot.pool %}
        <label>
          <input type="radio"
                 name="selection[{{ slot.handle }}]"
                 value="{{ option.id }}"
                 {{ not option.isAvailable ? 'disabled' }}>
          {{ option.description }}
          {% if slot.showPrices %}— {{ option.salePrice|commerceCurrency(cart.currency) }}{% endif %}
        </label>
      {% endfor %}
    </fieldset>
  {% endfor %}

  <button>Add to cart — {{ craft.present.price(config).unitTotal|commerceCurrency(cart.currency) }}</button>
</form>

For a build-your-own box, post quantities instead: selection[fillings][{{ option.id }}] = 3.

Showing boxes in the cart

Component line items are real line items, so a cart template that loops over cart.lineItems without knowing about Present will list a box's contents as separate rows. Two helpers fix that:

{% for item in craft.present.cartLineItems(cart) %}
  {% if craft.present.isComponent(item) %}
    <tr class="component"><td colspan="2">↳ {{ item.qty }} × {{ item.description }}</td></tr>
  {% else %}
    {% set group = craft.present.group(item) %}
    <tr>
      <td>{{ item.qty }} × {{ item.description }}</td>
      <td>{{ (group ? group.total : item.total)|commerceCurrency(cart.currency) }}</td>
    </tr>
  {% endif %}
{% endfor %}

Remove a whole box with present/cart/remove and its groupKey, never by deleting a line item.

Stock

{% set availability = craft.present.availability(bundle) %}

{% if not availability.isAvailable %}
  <p>Out of stock — {{ availability.reason }}</p>
{% elseif not availability.isUnlimited and availability.maxBuildable < 5 %}
  <p>Only {{ availability.maxBuildable }} left.</p>
{% endif %}

Without a configuration this is the optimistic answer: whether some box can still be built. Pass a configuration for the exact one.

Fulfilment

{% for key, fulfillment in craft.present.fulfillment(order) %}
  <p>{{ fulfillment.qtyFulfilled }} of {{ fulfillment.qtyOrdered }} shipped — {{ fulfillment.statusLabel }}</p>

  {% for blocker in fulfillment.blockers %}
    <li>Waiting on {{ blocker.qtyOutstanding }} × {{ blocker.description }}</li>
  {% endfor %}
{% endfor %}

Documentation

Console

php craft present/bundles/check                  # every bundle, what can be built, what is wrong
php craft present/bundles/check --problems-only
php craft present/bundles/check --type=giftBox

Testing

74 integration checks, run against a real Craft + Commerce install:

cd ~/Sites/plugin-testing
docker exec -w /var/www/html ddev-plugin-testing-web php /var/www/craft-present/tests/integration/checks.php
docker exec -w /var/www/html ddev-plugin-testing-web bash /var/www/craft-present/tests/integration/cp-smoke.sh

The suite is idempotent and self-cleaning. tests/integration/sweep.php clears fixtures left by a run that was killed part-way.