DEV Community

WebKoding
WebKoding

Posted on

EU Digital Product Passport in WooCommerce: Implementing ESPR Compliance with Structured Data and QR Codes

The EU's Ecodesign for Sustainable Products Regulation (ESPR) mandates Digital Product Passports (DPP) for most physical goods sold in Europe starting 2026–2030 (phased by category). If you run a WooCommerce store shipping to EU customers, your products will need machine-readable passports containing lifecycle data, materials, repairability scores, and recycling instructions — all accessible via a physical carrier (QR code, RFID, or datamatrix).

This article walks through how we built a self-hosted WooCommerce plugin to generate DPP-compliant structured data and printable QR codes — no third-party SaaS, no ongoing fees.


What Is a Digital Product Passport?

A DPP is a structured data record attached to a physical product containing:

  • Product identity: GTIN/EAN, model, manufacturer, batch/serial
  • Materials & substances: Bill of materials, hazardous substances (SVHC list)
  • Repairability: Spare parts availability, repair manual links, disassembly score
  • Environmental footprint: Carbon footprint (kg CO₂e), energy efficiency class
  • End-of-life: Recycling instructions, collection points, recyclate content %
  • Compliance documents: CE declaration, REACH compliance, test reports

The data must be accessible via a carrier (QR code printed on product/packaging) pointing to a URL that returns structured data in a machine-readable format (JSON-LD recommended by the EU).


1. The Data Model — WooCommerce Product Meta

We store DPP data as WooCommerce product meta, mapped to the EU DPP data model:

<?php
declare(strict_types=1);

class SP_DPP_Data_Model {

    public const FIELDS = [
        // Identity
        'dpp_gtin'                   => 'sanitize_text_field',
        'dpp_manufacturer_name'      => 'sanitize_text_field',
        'dpp_manufacturer_country'   => 'sanitize_text_field',
        'dpp_batch_number'           => 'sanitize_text_field',

        // Materials
        'dpp_main_material'          => 'sanitize_text_field',
        'dpp_recycled_content_pct'   => 'absint',
        'dpp_hazardous_substances'   => 'sanitize_textarea_field',

        // Repairability
        'dpp_repairability_score'    => 'sanitize_text_field',
        'dpp_spare_parts_url'        => 'esc_url_raw',
        'dpp_repair_manual_url'      => 'esc_url_raw',
        'dpp_warranty_years'         => 'absint',

        // Environmental
        'dpp_carbon_footprint_kg'    => 'sanitize_text_field',
        'dpp_energy_class'           => 'sanitize_text_field',
        'dpp_energy_kwh_year'        => 'sanitize_text_field',

        // End of life
        'dpp_recycling_instructions' => 'sanitize_textarea_field',
        'dpp_disassembly_time_min'   => 'absint',
        'dpp_collection_point_url'   => 'esc_url_raw',

        // Compliance
        'dpp_ce_declaration_url'     => 'esc_url_raw',
        'dpp_reach_compliant'        => 'absint',
    ];

    public function save_product_meta( int $product_id ): void {
        if ( ! current_user_can( 'edit_product', $product_id ) ) return;

        if ( ! isset( $_POST['sp_dpp_nonce'] ) || ! wp_verify_nonce(
            sanitize_text_field( wp_unslash( $_POST['sp_dpp_nonce'] ) ),
            'sp_dpp_save_' . $product_id
        ) ) return;

        $product = wc_get_product( $product_id );
        if ( ! $product ) return;

        foreach ( self::FIELDS as $field => $sanitizer ) {
            if ( isset( $_POST[ $field ] ) ) {
                $value = call_user_func( $sanitizer, wp_unslash( $_POST[ $field ] ) );
                $product->update_meta_data( '_' . $field, $value );
            }
        }
        $product->save();
    }

    public function get_passport_data( int $product_id ): array {
        $product = wc_get_product( $product_id );
        if ( ! $product ) return [];

        $data = [];
        foreach ( array_keys( self::FIELDS ) as $field ) {
            $data[ $field ] = $product->get_meta( '_' . $field );
        }
        return $data;
    }
}
Enter fullscreen mode Exit fullscreen mode

2. JSON-LD Passport Endpoint (WP REST API)

The QR code points to a public REST endpoint returning JSON-LD:

<?php
declare(strict_types=1);

class SP_DPP_REST_Controller extends WP_REST_Controller {

    protected $namespace = 'sp-dpp/v1';
    protected $rest_base = 'passport';

    public function register_routes(): void {
        register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<product_id>[\d]+)', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_passport' ],
                'permission_callback' => '__return_true',  // Public
                'args'                => [
                    'product_id' => [
                        'validate_callback' => fn($v) => is_numeric($v),
                        'sanitize_callback' => 'absint',
                    ],
                ],
            ],
        ] );
    }

    public function get_passport( WP_REST_Request $request ): WP_REST_Response {
        $product_id = $request->get_param( 'product_id' );
        $product    = wc_get_product( $product_id );

        if ( ! $product || ! $product->is_visible() ) {
            return new WP_REST_Response( [ 'error' => 'Product not found' ], 404 );
        }

        $model = new SP_DPP_Data_Model();
        $data  = $model->get_passport_data( $product_id );

        $passport = [
            '@context' => [
                'schema' => 'https://schema.org/',
                'dpp'    => 'https://digital-product-passport.eu/ns#',
            ],
            '@type'       => [ 'schema:Product', 'dpp:DigitalProductPassport' ],
            '@id'         => get_permalink( $product_id ),
            'schema:name' => $product->get_name(),
            'schema:gtin' => $data['dpp_gtin'],
            'dpp:materials' => [
                'dpp:primaryMaterial'    => $data['dpp_main_material'],
                'dpp:recycledContentPct' => (int) $data['dpp_recycled_content_pct'],
                'dpp:hazardousSubstances' => json_decode( $data['dpp_hazardous_substances'] ?: '[]', true ),
            ],
            'dpp:repairability' => [
                'dpp:score'         => $data['dpp_repairability_score'],
                'dpp:sparePartsUrl' => $data['dpp_spare_parts_url'],
                'dpp:repairManual'  => $data['dpp_repair_manual_url'],
                'dpp:warrantyYears' => (int) $data['dpp_warranty_years'],
            ],
            'dpp:environmental' => [
                'dpp:carbonFootprintKg' => (float) $data['dpp_carbon_footprint_kg'],
                'dpp:energyClass'       => $data['dpp_energy_class'],
                'dpp:energyKwhYear'     => (float) $data['dpp_energy_kwh_year'],
            ],
            'dpp:endOfLife' => [
                'dpp:recyclingInstructions' => $data['dpp_recycling_instructions'],
                'dpp:disassemblyTimeMin'    => (int) $data['dpp_disassembly_time_min'],
                'dpp:collectionPointUrl'    => $data['dpp_collection_point_url'],
            ],
            'dpp:compliance' => [
                'dpp:ceDeclarationUrl' => $data['dpp_ce_declaration_url'],
                'dpp:reachCompliant'   => (bool) $data['dpp_reach_compliant'],
            ],
            'dpp:generatedAt' => gmdate( 'c' ),
        ];

        $response = new WP_REST_Response( $passport, 200 );
        $response->header( 'Content-Type', 'application/ld+json' );
        $response->header( 'Cache-Control', 'public, max-age=3600' );
        return $response;
    }
}
Enter fullscreen mode Exit fullscreen mode

Endpoint URL: https://yourstore.com/wp-json/sp-dpp/v1/passport/123


3. QR Code Generation (No External API)

<?php
declare(strict_types=1);

class SP_DPP_QR_Generator {

    public function generate_qr( int $product_id, int $size = 300 ): string {
        $passport_url = rest_url( 'sp-dpp/v1/passport/' . $product_id );

        $cache_key = 'sp_dpp_qr_' . $product_id . '_' . $size;
        $cached    = get_transient( $cache_key );
        if ( $cached ) return $cached;

        require_once SP_DPP_PATH . 'vendor/phpqrcode/qrlib.php';

        ob_start();
        QRcode::png( $passport_url, false, QR_ECLEVEL_M, 10, 2 );
        $raw_png = ob_get_clean();

        $data_uri = 'data:image/png;base64,' . base64_encode( $raw_png );
        set_transient( $cache_key, $data_uri, WEEK_IN_SECONDS );

        return $data_uri;
    }

    public function save_qr_file( int $product_id ): string {
        $upload_dir = wp_upload_dir();
        $dpp_dir    = trailingslashit( $upload_dir['basedir'] ) . 'sp-dpp-qr/';

        if ( ! file_exists( $dpp_dir ) ) {
            wp_mkdir_p( $dpp_dir );
            file_put_contents( $dpp_dir . 'index.php', '<?php // Silence is golden.' );
        }

        $filename = 'dpp-qr-' . $product_id . '.png';
        require_once SP_DPP_PATH . 'vendor/phpqrcode/qrlib.php';
        QRcode::png( rest_url( 'sp-dpp/v1/passport/' . $product_id ), $dpp_dir . $filename, QR_ECLEVEL_M, 10, 2 );

        return trailingslashit( $upload_dir['baseurl'] ) . 'sp-dpp-qr/' . $filename;
    }
}
Enter fullscreen mode Exit fullscreen mode

4. Admin Product Tab

<?php
add_filter( 'woocommerce_product_data_tabs', function( array $tabs ): array {
    $tabs['sp_dpp'] = [
        'label'    => __( 'Digital Passport', 'sp-dpp' ),
        'target'   => 'sp_dpp_product_data',
        'priority' => 85,
    ];
    return $tabs;
} );

add_action( 'woocommerce_product_data_panels', function(): void {
    global $post;
    wp_nonce_field( 'sp_dpp_save_' . $post->ID, 'sp_dpp_nonce' );
    echo '<div id="sp_dpp_product_data" class="panel woocommerce_options_panel">';

    echo '<div class="options_group">';
    woocommerce_wp_text_input([
        'id'          => 'dpp_gtin',
        'label'       => __( 'GTIN / EAN', 'sp-dpp' ),
        'description' => __( 'Global Trade Item Number', 'sp-dpp' ),
        'desc_tip'    => true,
        'value'       => get_post_meta( $post->ID, '_dpp_gtin', true ),
    ]);
    woocommerce_wp_text_input([
        'id'    => 'dpp_repairability_score',
        'label' => __( 'Repairability Score (e.g. 7.5/10)', 'sp-dpp' ),
        'value' => get_post_meta( $post->ID, '_dpp_repairability_score', true ),
    ]);
    woocommerce_wp_text_input([
        'id'    => 'dpp_carbon_footprint_kg',
        'label' => __( 'Carbon Footprint (kg CO₂e)', 'sp-dpp' ),
        'value' => get_post_meta( $post->ID, '_dpp_carbon_footprint_kg', true ),
    ]);
    echo '</div>';

    // QR preview
    $qr  = new SP_DPP_QR_Generator();
    $src = $qr->generate_qr( $post->ID, 200 );
    echo '<div class="options_group">';
    echo '<p><strong>' . esc_html__( 'DPP QR Code', 'sp-dpp' ) . '</strong></p>';
    echo '<img src="' . esc_attr( $src ) . '" width="150" />';
    echo '<p><a href="' . esc_url( rest_url( 'sp-dpp/v1/passport/' . $post->ID ) ) . '" target="_blank">'
        . esc_html__( 'View Passport JSON-LD', 'sp-dpp' ) . '</a></p>';
    echo '</div>';
    echo '</div>';
} );
Enter fullscreen mode Exit fullscreen mode

5. Bulk Import via WooCommerce CSV

<?php
add_filter( 'woocommerce_csv_product_import_mapping_default_columns', function( array $columns ): array {
    return array_merge( $columns, [
        'GTIN'                => 'dpp_gtin',
        'Manufacturer'        => 'dpp_manufacturer_name',
        'Carbon Footprint kg' => 'dpp_carbon_footprint_kg',
        'Recycled Content %'  => 'dpp_recycled_content_pct',
        'Repairability Score' => 'dpp_repairability_score',
        'Energy Class'        => 'dpp_energy_class',
    ] );
} );

add_filter( 'woocommerce_product_import_pre_insert_product_object',
function( WC_Product $product, array $data ): WC_Product {
    foreach ( array_keys( SP_DPP_Data_Model::FIELDS ) as $field ) {
        if ( isset( $data[ $field ] ) ) {
            $product->update_meta_data( '_' . $field, sanitize_text_field( $data[ $field ] ) );
        }
    }
    return $product;
}, 10, 2 );
Enter fullscreen mode Exit fullscreen mode

ESPR Rollout Timeline

Year Categories
2026 Batteries, textiles, electronics
2027 Furniture, steel, cement, chemicals
2028–2030 All remaining categories

The DPP endpoint must stay accessible for the full product lifecycle — typically 10+ years after sale.


Why Self-Hosted?

DPP SaaS platforms (Circularise, Renoon, Fairly Made) charge €200–500/mo per brand. For most WooCommerce stores, a one-time plugin generating compliant JSON-LD and QR codes covers the full technical requirement without ongoing costs.

The full plugin is on CodeCanyon: EU Digital Product Passport for WooCommerce (ESPR)

Questions about the JSON-LD structure or ESPR data requirements? Drop them in the comments.

Top comments (0)