Advanced Custom Fields is genuinely useful, but it’s also a dependency your entire content structure relies on — if it’s ever deactivated, every custom field on the site stops rendering. For a lot of simpler use cases, core WordPress functions do the job without that dependency.
Registering a custom post type
add_action('init', function() {
register_post_type('portfolio_item', [
'labels' => ['name' => 'Portfolio Items', 'singular_name' => 'Portfolio Item'],
'public' => true,
'has_archive' => true,
'show_in_rest' => true, // needed for block editor support
'supports' => ['title', 'editor', 'thumbnail', 'custom-fields'],
'menu_icon' => 'dashicons-portfolio',
]);
});
show_in_rest is easy to forget and its absence is a common source of “why doesn’t the block editor work on this post type” questions.
Adding meta fields with a proper meta box
add_action('add_meta_boxes', function() {
add_meta_box('project_url', 'Project URL', function($post) {
$value = get_post_meta($post->ID, '_project_url', true);
wp_nonce_field('save_project_url', 'project_url_nonce');
echo '<input type="url" name="project_url" value="' . esc_attr($value) . '" style="width:100%">';
}, 'portfolio_item');
});
add_action('save_post', function($post_id) {
if (!isset($_POST['project_url_nonce']) || !wp_verify_nonce($_POST['project_url_nonce'], 'save_project_url')) return;
if (isset($_POST['project_url'])) {
update_post_meta($post_id, '_project_url', esc_url_raw($_POST['project_url']));
}
});
Where this genuinely falls short of ACF
Repeater fields, conditional field logic, and relationship fields between post types get tedious fast in raw PHP — that’s real, non-trivial work ACF handles well. For a handful of straightforward fields tied to a custom post type, native functions are lighter and remove a dependency. For a content model with dozens of interrelated fields, ACF (or ACF Pro specifically for repeaters) is usually the better time investment.