paginate_links() returns NULL instead of the pagination links, but pagination is actually happening

paginate_links() is not a template/Loop tag like the_title() or next_posts_link(). It is a general purpose pagination function. It does not do anything if given zero parameters. It does not assume data from the main query, or from any other query, if passed zero parameters. It just does nothing. You can see that if you read the source for the function.

Yes, technically all parameters are optional, but the defaults output nothing which, again, you can see in the source. The default total argument is 1 but…

2017          $total = (int) $total;
2018          if ( $total < 2 )
2019                  return;

https://core.trac.wordpress.org/browser/tags/3.8.1/src/wp-includes/general-template.php#L2017

If you want to use paginate_links() you have to pass it a set of arguments appropriate to your data/query. If you want to use this with the main query, as it appears you do, there is an example in the Codex that does just that:

global $wp_query;

$big = 999999999; // need an unlikely integer

echo paginate_links( array(
    'base' => str_replace( $big, '%#%', esc_url( get_pagenum_link( $big ) ) ),
    'format' => '?paged=%#%',
    'current' => max( 1, get_query_var('paged') ),
    'total' => $wp_query->max_num_pages
) );